update 2026-05-27 23:25:57

This commit is contained in:
action committed 2026-05-27 23:25:57 +08:00
1 parent 84f707b52b
commit d0159b49a5
759 files changed
+362365

No files matched your search

+173
View File
@@ -0,0 +1,173 @@
# Copyright (C) 2018-2020 L-WRT Team
# Copyright (C) 2021-2025 xiaorouji
# Copyright (C) 2026 Openwrt-Passwall Organization
#
# This is free software, licensed under the GNU General Public License v3.
include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-passwall
PKG_VERSION:=26.5.20
PKG_RELEASE:=1
PKG_PO_VERSION:=$(PKG_VERSION)
PKG_CONFIG_DEPENDS:= \
CONFIG_PACKAGE_$(PKG_NAME)_Iptables_Transparent_Proxy \
CONFIG_PACKAGE_$(PKG_NAME)_Nftables_Transparent_Proxy \
CONFIG_PACKAGE_$(PKG_NAME)_INCLUDE_Geoview \
CONFIG_PACKAGE_$(PKG_NAME)_INCLUDE_Haproxy \
CONFIG_PACKAGE_$(PKG_NAME)_INCLUDE_Hysteria \
CONFIG_PACKAGE_$(PKG_NAME)_INCLUDE_NaiveProxy \
CONFIG_PACKAGE_$(PKG_NAME)_INCLUDE_Shadowsocks_Rust_Client \
CONFIG_PACKAGE_$(PKG_NAME)_INCLUDE_Shadowsocks_Rust_Server \
CONFIG_PACKAGE_$(PKG_NAME)_INCLUDE_ShadowsocksR_Libev_Client \
CONFIG_PACKAGE_$(PKG_NAME)_INCLUDE_ShadowsocksR_Libev_Server \
CONFIG_PACKAGE_$(PKG_NAME)_INCLUDE_Shadow_TLS \
CONFIG_PACKAGE_$(PKG_NAME)_INCLUDE_Simple_Obfs \
CONFIG_PACKAGE_$(PKG_NAME)_INCLUDE_SingBox \
CONFIG_PACKAGE_$(PKG_NAME)_INCLUDE_V2ray_Geodata \
CONFIG_PACKAGE_$(PKG_NAME)_INCLUDE_V2ray_Plugin \
CONFIG_PACKAGE_$(PKG_NAME)_INCLUDE_Xray \
CONFIG_PACKAGE_$(PKG_NAME)_INCLUDE_Xray_Plugin
LUCI_TITLE:=LuCI support for PassWall
LUCI_PKGARCH:=all
LUCI_DEPENDS:=+coreutils +coreutils-base64 +coreutils-nohup +curl \
+chinadns-ng +dns2socks +dnsmasq-full +ip-full \
+libuci-lua +lua +luci-compat +luci-lib-jsonc \
+microsocks +resolveip +tcping +lyaml
define Package/$(PKG_NAME)/config
menu "Configuration"
depends on PACKAGE_$(PKG_NAME)
config PACKAGE_$(PKG_NAME)_Iptables_Transparent_Proxy
bool "Iptables Transparent Proxy"
select PACKAGE_ipset
select PACKAGE_ipt2socks
select PACKAGE_iptables
select PACKAGE_iptables-zz-legacy
select PACKAGE_iptables-mod-conntrack-extra
select PACKAGE_iptables-mod-iprange
select PACKAGE_iptables-mod-socket
select PACKAGE_iptables-mod-tproxy
select PACKAGE_kmod-ipt-nat
default y if ! PACKAGE_firewall4
config PACKAGE_$(PKG_NAME)_Nftables_Transparent_Proxy
bool "Nftables Transparent Proxy"
select PACKAGE_ipt2socks
select PACKAGE_nftables
select PACKAGE_kmod-nft-socket
select PACKAGE_kmod-nft-tproxy
select PACKAGE_kmod-nft-nat
default y if PACKAGE_firewall4
config PACKAGE_$(PKG_NAME)_INCLUDE_Geoview
bool "Include Geoview"
select PACKAGE_geoview
default y if aarch64||arm||i386||x86_64
config PACKAGE_$(PKG_NAME)_INCLUDE_Haproxy
bool "Include Haproxy"
select PACKAGE_haproxy
default y if aarch64||arm||i386||x86_64
config PACKAGE_$(PKG_NAME)_INCLUDE_Hysteria
bool "Include Hysteria"
select PACKAGE_hysteria
default n
config PACKAGE_$(PKG_NAME)_INCLUDE_NaiveProxy
bool "Include NaiveProxy"
depends on !(arc||armeb||loongarch64||mips||mips64||powerpc||TARGET_gemini)
select PACKAGE_naiveproxy
default n
config PACKAGE_$(PKG_NAME)_INCLUDE_Shadowsocks_Rust_Client
bool "Include Shadowsocks Rust Client"
depends on !i386
select PACKAGE_shadowsocks-rust-sslocal
default y if aarch64||x86_64
config PACKAGE_$(PKG_NAME)_INCLUDE_Shadowsocks_Rust_Server
bool "Include Shadowsocks Rust Server"
depends on !i386
select PACKAGE_shadowsocks-rust-ssserver
default n
config PACKAGE_$(PKG_NAME)_INCLUDE_ShadowsocksR_Libev_Client
bool "Include ShadowsocksR Libev Client"
select PACKAGE_shadowsocksr-libev-ssr-local
select PACKAGE_shadowsocksr-libev-ssr-redir
default y
config PACKAGE_$(PKG_NAME)_INCLUDE_ShadowsocksR_Libev_Server
bool "Include ShadowsocksR Libev Server"
select PACKAGE_shadowsocksr-libev-ssr-server
default n
config PACKAGE_$(PKG_NAME)_INCLUDE_Shadow_TLS
bool "Include Shadow-TLS"
select PACKAGE_shadow-tls
depends on aarch64||arm||x86_64
default n
config PACKAGE_$(PKG_NAME)_INCLUDE_Simple_Obfs
bool "Include Simple-Obfs (Shadowsocks Plugin)"
select PACKAGE_simple-obfs-client
default y
config PACKAGE_$(PKG_NAME)_INCLUDE_SingBox
bool "Include Sing-Box"
select PACKAGE_sing-box
default y if aarch64||arm||i386||x86_64
config PACKAGE_$(PKG_NAME)_INCLUDE_V2ray_Geodata
bool "Include V2ray_Geodata"
select PACKAGE_v2ray-geoip
select PACKAGE_v2ray-geosite
default n
config PACKAGE_$(PKG_NAME)_INCLUDE_V2ray_Plugin
bool "Include V2ray-Plugin (Shadowsocks Plugin)"
select PACKAGE_v2ray-plugin
default y if aarch64||arm||i386||x86_64
config PACKAGE_$(PKG_NAME)_INCLUDE_Xray
bool "Include Xray"
select PACKAGE_xray-core
select PACKAGE_unzip
default y if aarch64||arm||i386||x86_64
config PACKAGE_$(PKG_NAME)_INCLUDE_Xray_Plugin
bool "Include Xray-Plugin (Shadowsocks Plugin)"
select PACKAGE_xray-plugin
default n
endmenu
endef
define Package/$(PKG_NAME)/conffiles
/etc/config/passwall
/etc/config/passwall_server
/usr/share/passwall/rules/direct_host
/usr/share/passwall/rules/direct_ip
/usr/share/passwall/rules/proxy_host
/usr/share/passwall/rules/proxy_ip
/usr/share/passwall/rules/block_host
/usr/share/passwall/rules/block_ip
/usr/share/passwall/rules/lanlist_ipv4
/usr/share/passwall/rules/lanlist_ipv6
/usr/share/passwall/rules/domains_excluded
endef
define Package/$(PKG_NAME)/postrm
#!/bin/sh
rm -f $${IPKG_INSTROOT}/usr/share/passwall/rules/*.nft
exit 0
endef
include $(TOPDIR)/feeds/luci/luci.mk
# call BuildPackage - OpenWrt buildroot signature
@@ -0,0 +1,2 @@
/*! Sortable 1.15.7 - MIT | git://github.com/SortableJS/Sortable.git */
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t=t||self).Sortable=e()}(this,function(){"use strict";function o(t,e){(null==e||e>t.length)&&(e=t.length);for(var n=0,o=Array(e);n<e;n++)o[n]=t[n];return o}function i(t,e,n){return(e=function(t){t=function(t,e){if("object"!=typeof t||!t)return t;var n=t[Symbol.toPrimitive];if(void 0===n)return("string"===e?String:Number)(t);e=n.call(t,e||"default");if("object"!=typeof e)return e;throw new TypeError("@@toPrimitive must return a primitive value.")}(t,"string");return"symbol"==typeof t?t:t+""}(e))in t?Object.defineProperty(t,e,{value:n,enumerable:!0,configurable:!0,writable:!0}):t[e]=n,t}function a(){return(a=Object.assign?Object.assign.bind():function(t){for(var e=1;e<arguments.length;e++){var n,o=arguments[e];for(n in o)!{}.hasOwnProperty.call(o,n)||(t[n]=o[n])}return t}).apply(null,arguments)}function r(e,t){var n,o=Object.keys(e);return Object.getOwnPropertySymbols&&(n=Object.getOwnPropertySymbols(e),t&&(n=n.filter(function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable})),o.push.apply(o,n)),o}function I(e){for(var t=1;t<arguments.length;t++){var n=null!=arguments[t]?arguments[t]:{};t%2?r(Object(n),!0).forEach(function(t){i(e,t,n[t])}):Object.getOwnPropertyDescriptors?Object.defineProperties(e,Object.getOwnPropertyDescriptors(n)):r(Object(n)).forEach(function(t){Object.defineProperty(e,t,Object.getOwnPropertyDescriptor(n,t))})}return e}function l(t,e){if(null==t)return{};var n,o=function(t,e){if(null==t)return{};var n,o={};for(n in t)if({}.hasOwnProperty.call(t,n)){if(-1!==e.indexOf(n))continue;o[n]=t[n]}return o}(t,e);if(Object.getOwnPropertySymbols)for(var i=Object.getOwnPropertySymbols(t),r=0;r<i.length;r++)n=i[r],-1===e.indexOf(n)&&{}.propertyIsEnumerable.call(t,n)&&(o[n]=t[n]);return o}function e(t){return function(t){if(Array.isArray(t))return o(t)}(t)||function(t){if("undefined"!=typeof Symbol&&null!=t[Symbol.iterator]||null!=t["@@iterator"])return Array.from(t)}(t)||function(t,e){if(t){if("string"==typeof t)return o(t,e);var n={}.toString.call(t).slice(8,-1);return"Map"===(n="Object"===n&&t.constructor?t.constructor.name:n)||"Set"===n?Array.from(t):"Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)?o(t,e):void 0}}(t)||function(){throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function s(t){return(s="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(t)}function t(t){if("undefined"!=typeof window&&window.navigator)return!!navigator.userAgent.match(t)}var y=t(/(?:Trident.*rv[ :]?11\.|msie|iemobile|Windows Phone)/i),w=t(/Edge/i),c=t(/firefox/i),u=t(/safari/i)&&!t(/chrome/i)&&!t(/android/i),d=t(/iP(ad|od|hone)/i),n=t(/chrome/i)&&t(/android/i),h={capture:!1,passive:!1};function f(t,e,n){t.addEventListener(e,n,!y&&h)}function p(t,e,n){t.removeEventListener(e,n,!y&&h)}function g(t,e){if(e&&(">"===e[0]&&(e=e.substring(1)),t))try{if(t.matches)return t.matches(e);if(t.msMatchesSelector)return t.msMatchesSelector(e);if(t.webkitMatchesSelector)return t.webkitMatchesSelector(e)}catch(t){return}}function m(t){return t.host&&t!==document&&t.host.nodeType&&t.host!==t?t.host:t.parentNode}function P(t,e,n,o){if(t){n=n||document;do{if(null!=e&&(">"!==e[0]||t.parentNode===n)&&g(t,e)||o&&t===n)return t}while(t!==n&&(t=m(t)))}return null}var v,b=/\s+/g;function k(t,e,n){var o;t&&e&&(t.classList?t.classList[n?"add":"remove"](e):(o=(" "+t.className+" ").replace(b," ").replace(" "+e+" "," "),t.className=(o+(n?" "+e:"")).replace(b," ")))}function R(t,e,n){var o=t&&t.style;if(o){if(void 0===n)return document.defaultView&&document.defaultView.getComputedStyle?n=document.defaultView.getComputedStyle(t,""):t.currentStyle&&(n=t.currentStyle),void 0===e?n:n[e];o[e=!(e in o||-1!==e.indexOf("webkit"))?"-webkit-"+e:e]=n+("string"==typeof n?"":"px")}}function D(t,e){var n="";if("string"==typeof t)n=t;else do{var o=R(t,"transform")}while(o&&"none"!==o&&(n=o+" "+n),!e&&(t=t.parentNode));var i=window.DOMMatrix||window.WebKitCSSMatrix||window.CSSMatrix||window.MSCSSMatrix;return i&&new i(n)}function E(t,e,n){if(t){var o=t.getElementsByTagName(e),i=0,r=o.length;if(n)for(;i<r;i++)n(o[i],i);return o}return[]}function O(){var t=document.scrollingElement;return t||document.documentElement}function X(t,e,n,o,i){if(t.getBoundingClientRect||t===window){var r,a,l,s,c,u,d=t!==window&&t.parentNode&&t!==O()?(a=(r=t.getBoundingClientRect()).top,l=r.left,s=r.bottom,c=r.right,u=r.height,r.width):(l=a=0,s=window.innerHeight,c=window.innerWidth,u=window.innerHeight,window.innerWidth);if((e||n)&&t!==window&&(i=i||t.parentNode,!y))do{if(i&&i.getBoundingClientRect&&("none"!==R(i,"transform")||n&&"static"!==R(i,"position"))){var h=i.getBouLine truncated
@@ -0,0 +1 @@
var QRCode;!function(){function t(t){this.mode=r.MODE_8BIT_BYTE,this.data=t,this.parsedData=[];for(var e=0,o=this.data.length;e<o;e++){var i=[],n=this.data.charCodeAt(e);n>65536?(i[0]=240|(1835008&n)>>>18,i[1]=128|(258048&n)>>>12,i[2]=128|(4032&n)>>>6,i[3]=128|63&n):n>2048?(i[0]=224|(61440&n)>>>12,i[1]=128|(4032&n)>>>6,i[2]=128|63&n):n>128?(i[0]=192|(1984&n)>>>6,i[1]=128|63&n):i[0]=n,this.parsedData.push(i)}this.parsedData=Array.prototype.concat.apply([],this.parsedData),this.parsedData.length!=this.data.length&&(this.parsedData.unshift(191),this.parsedData.unshift(187),this.parsedData.unshift(239))}function e(t,e){this.typeNumber=t,this.errorCorrectLevel=e,this.modules=null,this.moduleCount=0,this.dataCache=null,this.dataList=[]}t.prototype={getLength:function(t){return this.parsedData.length},write:function(t){for(var e=0,r=this.parsedData.length;e<r;e++)t.put(this.parsedData[e],8)}},e.prototype={addData:function(e){var r=new t(e);this.dataList.push(r),this.dataCache=null},isDark:function(t,e){if(t<0||this.moduleCount<=t||e<0||this.moduleCount<=e)throw new Error(t+","+e);return this.modules[t][e]},getModuleCount:function(){return this.moduleCount},make:function(){this.makeImpl(!1,this.getBestMaskPattern())},makeImpl:function(t,r){this.moduleCount=4*this.typeNumber+17,this.modules=new Array(this.moduleCount);for(var o=0;o<this.moduleCount;o++){this.modules[o]=new Array(this.moduleCount);for(var i=0;i<this.moduleCount;i++)this.modules[o][i]=null}this.setupPositionProbePattern(0,0),this.setupPositionProbePattern(this.moduleCount-7,0),this.setupPositionProbePattern(0,this.moduleCount-7),this.setupPositionAdjustPattern(),this.setupTimingPattern(),this.setupTypeInfo(t,r),this.typeNumber>=7&&this.setupTypeNumber(t),null==this.dataCache&&(this.dataCache=e.createData(this.typeNumber,this.errorCorrectLevel,this.dataList)),this.mapData(this.dataCache,r)},setupPositionProbePattern:function(t,e){for(var r=-1;r<=7;r++)if(!(t+r<=-1||this.moduleCount<=t+r))for(var o=-1;o<=7;o++)e+o<=-1||this.moduleCount<=e+o||(this.modules[t+r][e+o]=0<=r&&r<=6&&(0==o||6==o)||0<=o&&o<=6&&(0==r||6==r)||2<=r&&r<=4&&2<=o&&o<=4)},getBestMaskPattern:function(){for(var t=0,e=0,r=0;r<8;r++){this.makeImpl(!0,r);var o=g.getLostPoint(this);(0==r||t>o)&&(t=o,e=r)}return e},createMovieClip:function(t,e,r){var o=t.createEmptyMovieClip(e,r);this.make();for(var i=0;i<this.modules.length;i++)for(var n=1*i,a=0;a<this.modules[i].length;a++){var s=1*a;this.modules[i][a]&&(o.beginFill(0,100),o.moveTo(s,n),o.lineTo(s+1,n),o.lineTo(s+1,n+1),o.lineTo(s,n+1),o.endFill())}return o},setupTimingPattern:function(){for(var t=8;t<this.moduleCount-8;t++)null==this.modules[t][6]&&(this.modules[t][6]=t%2==0);for(var e=8;e<this.moduleCount-8;e++)null==this.modules[6][e]&&(this.modules[6][e]=e%2==0)},setupPositionAdjustPattern:function(){for(var t=g.getPatternPosition(this.typeNumber),e=0;e<t.length;e++)for(var r=0;r<t.length;r++){var o=t[e],i=t[r];if(null==this.modules[o][i])for(var n=-2;n<=2;n++)for(var a=-2;a<=2;a++)this.modules[o+n][i+a]=-2==n||2==n||-2==a||2==a||0==n&&0==a}},setupTypeNumber:function(t){for(var e=g.getBCHTypeNumber(this.typeNumber),r=0;r<18;r++){var o=!t&&1==(e>>r&1);this.modules[Math.floor(r/3)][r%3+this.moduleCount-8-3]=o}for(r=0;r<18;r++){o=!t&&1==(e>>r&1);this.modules[r%3+this.moduleCount-8-3][Math.floor(r/3)]=o}},setupTypeInfo:function(t,e){for(var r=this.errorCorrectLevel<<3|e,o=g.getBCHTypeInfo(r),i=0;i<15;i++){var n=!t&&1==(o>>i&1);i<6?this.modules[i][8]=n:i<8?this.modules[i+1][8]=n:this.modules[this.moduleCount-15+i][8]=n}for(i=0;i<15;i++){n=!t&&1==(o>>i&1);i<8?this.modules[8][this.moduleCount-i-1]=n:i<9?this.modules[8][15-i-1+1]=n:this.modules[8][15-i-1]=n}this.modules[this.moduleCount-8][8]=!t},mapData:function(t,e){for(var r=-1,o=this.moduleCount-1,i=7,n=0,a=this.moduleCount-1;a>0;a-=2)for(6==a&&a--;;){for(var s=0;s<2;s++)if(null==this.modules[o][a-s]){var h=!1;n<t.length&&(h=1==(t[n]>>>i&1)),g.getMask(e,o,a-s)&&(h=!h),this.modules[o][a-s]=h,-1==--i&&(n++,i=7)}if((o+=r)<0||this.moduleCount<=o){o-=r,r=-r;break}}}},e.PAD0=236,e.PAD1=17,e.createData=function(t,r,o){for(var i=m.getRSBlocks(t,r),n=new _,a=0;a<o.length;a++){var s=o[a];n.put(s.mode,4),n.put(s.getLength(),g.getLengthInBits(s.mode,t)),s.write(n)}var h=0;for(a=0;a<i.length;a++)h+=i[a].dataCount;if(n.getLengthInBits()>8*h)throw new Error("code length overflow. ("+n.getLengthInBits()+">"+8*h+")");for(n.getLengthInBits()+4<=8*h&&n.put(0,4);n.getLengthInBits()%8!=0;)n.putBit(!1);for(;!(n.getLengthInBits()>=8*h||(n.put(e.PAD0,8),n.getLengthInBits()>=8*h));)n.put(e.PAD1,8);return e.createBytes(n,i)},e.createBytes=function(t,e){for(var r=0,o=0,i=0,n=new Array(e.length),a=new Array(e.length),s=0;s<e.length;s++){var h=e[s].dataCount,l=e[s].totalCount-h;o=Math.max(o,h),i=Math.max(i,l),n[s]=new Array(h);for(var u=0;u<n[s].length;u++)n[s][u]=255&t.buffer[u+r];r+=h;var f=g.getErrorCorrectPolynomial(l),d=new p(n[s],f.getLength()-1).mod(f);a[s]=new Array(f.getLength()-1);for(u=0;u<a[s].length;u++)Line truncated
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,104 @@
local api = require "luci.passwall.api"
local appname = "passwall"
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
function s.remove(e, t)
sys.call("rm -rf /tmp/etc/passwall_tmp/dns_" .. t .. "*")
TypedSection.remove(e, 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
o = s:option(DummyValue, "interface", translate("Source Interface"))
o.cfgvalue = function(t, n)
local v = Value.cfgvalue(t, n) or '-'
return v
end
--[[
---- TCP No Redir Ports
o = s:option(Value, "tcp_no_redir_ports", translate("TCP No Redir Ports"))
o.default = "default"
o:value("disable", translate("No patterns are used"))
o:value("default", translate("Default"))
o:value("1:65535", translate("All"))
---- UDP No Redir Ports
o = s:option(Value, "udp_no_redir_ports", translate("UDP No Redir Ports"))
o.default = "default"
o:value("disable", translate("No patterns are used"))
o:value("default", translate("Default"))
o:value("1:65535", translate("All"))
---- TCP Redir Ports
o = s:option(Value, "tcp_redir_ports", translate("TCP Redir Ports"))
o.default = "default"
o:value("default", translate("Default"))
o:value("1:65535", translate("All"))
o:value("80,443", "80,443")
o:value("80:65535", "80 " .. translate("or more"))
o:value("1:443", "443 " .. translate("or less"))
---- UDP Redir Ports
o = s:option(Value, "udp_redir_ports", translate("UDP Redir Ports"))
o.default = "default"
o:value("default", translate("Default"))
o:value("1:65535", translate("All"))
o:value("53", "53")
]]--
return m
@@ -0,0 +1,604 @@
local api = require "luci.passwall.api"
local appname = "passwall"
m = Map(appname)
m.redirect = api.url("acl")
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"))
local fs = api.fs
local sys = api.sys
local has_singbox = api.finded_com("sing-box")
local has_xray = api.finded_com("xray")
local has_gfwlist = fs.access("/usr/share/passwall/rules/gfwlist")
local has_chnlist = fs.access("/usr/share/passwall/rules/chnlist")
local has_chnroute = fs.access("/usr/share/passwall/rules/chnroute")
local port_validate = function(self, value, t)
return value:gsub("-", ":")
end
local nodes_table = {}
for _, e in ipairs(api.get_valid_nodes()) do
nodes_table[#nodes_table + 1] = e
end
local normal_list = {}
for _, v in pairs(nodes_table) do
if v.node_type == "normal" then
normal_list[#normal_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") .. " " .. string.format("[%s %s]", s.port, translate("Port")),
group = "Socks"
}
end
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
-- [[ 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"))
local iface = api.get_network_devices()
for _, d in ipairs(iface) do
o:value(d.name, d.label)
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("Fill in the ports you don't want to be forwarded by the agent, with the highest priority.") ..
"</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 = "passwall/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
o = s:option(Flag, "use_global_config", translatef("Use global config"))
o.default = "0"
o.rmempty = false
o:depends({ _hide_node_option = "1", ['!reverse'] = true })
o = s:option(ListValue, "tcp_node", "<a style='color: red'>" .. translate("TCP Node") .. "</a>")
o.default = ""
o:depends({ _hide_node_option = false, use_global_config = false })
o.template = appname .. "/cbi/nodes_listvalue"
o.group = {}
o.remove = function(self, section)
m:del(section, self.option)
m:del(section, "udp_node")
end
o = s:option(DummyValue, "_tcp_node_bool", "")
o.template = "passwall/cbi/hidevalue"
o.value = "1"
o:depends({ tcp_node = "", ['!reverse'] = true })
o = s:option(ListValue, "udp_node", "<a style='color: red'>" .. translate("UDP Node") .. "</a>")
o.default = ""
o:value("", translate("Close"))
o:value("tcp", translate("Same as the tcp node"))
o:depends({ _tcp_node_bool = "1", _node_sel_other = "1" })
o.template = appname .. "/cbi/nodes_listvalue"
o.group = {"",""}
o.remove = function(self, section)
local v = s.fields["shunt_udp_node"]:formvalue(section)
if not v then
return m:del(section, self.option)
end
end
o = s:option(ListValue, "shunt_udp_node", "<a style='color: red'>" .. translate("UDP Node") .. "</a>")
o:value("close", translate("Close"))
o:value("tcp", translate("Same as the tcp node"))
o:depends({ _tcp_node_bool = "1", _node_sel_shunt = "1" })
o.cfgvalue = function(self, section)
local v = m:get(section, "udp_node") or ""
if v == "" then v = "close" end
if v ~= "close" and v ~= "tcp" then v = "tcp" end
return v
end
o.write = function(self, section, value)
if value == "close" then value = "" end
return m:set(section, "udp_node", value)
end
o = s:option(DummyValue, "_udp_node_bool", "")
o.template = "passwall/cbi/hidevalue"
o.value = "1"
o:depends({ udp_node = "", ['!reverse'] = true })
o:depends({ shunt_udp_node = "tcp" })
---- TCP Proxy Drop Ports
local TCP_PROXY_DROP_PORTS = m:get("@global_forwarding[0]", "tcp_proxy_drop_ports")
o = s:option(Value, "tcp_proxy_drop_ports", translate("TCP Proxy Drop Ports"))
o:value("", translate("Use global config") .. "(" .. TCP_PROXY_DROP_PORTS .. ")")
o:value("disable", translate("No patterns are used"))
o.validate = port_validate
o:depends({ use_global_config = true })
o:depends({ _tcp_node_bool = "1" })
---- UDP Proxy Drop Ports
local UDP_PROXY_DROP_PORTS = m:get("@global_forwarding[0]", "udp_proxy_drop_ports")
o = s:option(Value, "udp_proxy_drop_ports", translate("UDP Proxy Drop Ports"))
o:value("", translate("Use global config") .. "(" .. UDP_PROXY_DROP_PORTS .. ")")
o:value("disable", translate("No patterns are used"))
o:value("443", translate("QUIC"))
o.validate = port_validate
o:depends({ use_global_config = true })
o:depends({ _tcp_node_bool = "1" })
---- 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"), translatef("Only work with using the %s node.", "TCP"))
o:value("", translate("Use global config") .. "(" .. TCP_REDIR_PORTS .. ")")
o:value("1:65535", translate("All"))
o:value("80,443", "80,443")
o:value("80:65535", "80 " .. translate("or more"))
o:value("1:443", "443 " .. translate("or less"))
o.validate = port_validate
o:depends({ use_global_config = true })
o:depends({ _tcp_node_bool = "1" })
---- 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"), translatef("Only work with using the %s node.", "UDP"))
o:value("", translate("Use global config") .. "(" .. UDP_REDIR_PORTS .. ")")
o:value("1:65535", translate("All"))
o:value("53", "53")
o.validate = port_validate
o:depends({ use_global_config = true })
o:depends({ _udp_node_bool = "1" })
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:depends({ use_global_config = true })
o:depends({ _tcp_node_bool = "1" })
o = s:option(Flag, "use_direct_list", translatef("Use %s", translate("Direct List")))
o.default = "1"
o:depends({ _tcp_node_bool = "1" })
o = s:option(Flag, "use_proxy_list", translatef("Use %s", translate("Proxy List")))
o.default = "1"
o:depends({ _tcp_node_bool = "1" })
o = s:option(Flag, "use_block_list", translatef("Use %s", translate("Block List")))
o.default = "1"
o:depends({ _tcp_node_bool = "1" })
if has_gfwlist then
o = s:option(Flag, "use_gfw_list", translatef("Use %s", translate("GFW List")))
o.default = "1"
o:depends({ _tcp_node_bool = "1" })
end
if has_chnlist or has_chnroute then
o = s:option(ListValue, "chn_list", translate("China List"))
o:value("0", translate("Close(Not use)"))
o:value("direct", translate("Direct Connection"))
o:value("proxy", translate("Proxy"))
o.default = "direct"
o:depends({ _tcp_node_bool = "1" })
end
o = s:option(ListValue, "tcp_proxy_mode", "TCP " .. translate("Proxy Mode"))
o:value("disable", translate("No Proxy"))
o:value("proxy", translate("Proxy"))
o:depends({ _tcp_node_bool = "1" })
o = s:option(ListValue, "udp_proxy_mode", "UDP " .. translate("Proxy Mode"))
o:value("disable", translate("No Proxy"))
o:value("proxy", translate("Proxy"))
o:depends({ _udp_node_bool = "1" })
o = s:option(DummyValue, "switch_mode", " ")
o.template = appname .. "/global/proxy"
o:depends({ _tcp_node_bool = "1" })
-- Node → DNS Depends Settings
o = s:option(DummyValue, "_node_sel_shunt", "")
o.template = appname .. "/cbi/hidevalue"
o.value = "1"
o:depends({ tcp_node = "__always__" })
o = s:option(DummyValue, "_node_sel_other", "")
o.template = appname .. "/cbi/hidevalue"
o.value = "1"
o:depends({ _node_sel_shunt = "1", ['!reverse'] = true })
---- DNS
o = s:option(ListValue, "dns_shunt", "DNS " .. translate("Shunt"))
o.default = "chinadns-ng"
o:value("dnsmasq", "Dnsmasq")
o:value("chinadns-ng", translate("ChinaDNS-NG (recommended)"))
o:depends({ _tcp_node_bool = "1" })
o = s:option(DummyValue, "view_chinadns_log", " ")
o.template = appname .. "/acl/view_chinadns_log"
o = s:option(Flag, "filter_proxy_ipv6", translate("Filter Proxy Host IPv6"), translate("Experimental feature."))
o.default = "0"
o:depends({ _tcp_node_bool = "1" })
---- DNS Forward Mode
o = s:option(ListValue, "dns_mode", translate("Filter Mode"))
if api.is_finded("dns2socks") then
o:value("dns2socks", "dns2socks")
end
if has_singbox then
o:value("sing-box", "Sing-Box")
end
if has_xray then
o:value("xray", "Xray")
end
o:depends({ _tcp_node_bool = "1", _node_sel_other = "1" })
o.write = function(self, section, value)
if value == "dns2socks" then
m:del(section, "v2ray_dns_mode")
end
return ListValue.write(self, section, value)
end
o.remove = function(self, section)
local f = s.fields["tcp_node"]
local id_val = f and f:formvalue(section) or ""
if id_val == "" then
return m:del(section, self.option)
end
for _, v in pairs(nodes_table) do
if v.id == id_val then
local new_val = (v.type == "Xray") and "xray" or "sing-box"
m:set(section, self.option, new_val)
local dns_field = s.fields[v.type == "Xray" and "xray_dns_mode" or "singbox_dns_mode"]
local v2ray_dns_mode = dns_field and dns_field:formvalue(section)
if v2ray_dns_mode then
m:set(section, "v2ray_dns_mode", v2ray_dns_mode)
end
break
end
end
end
o = s:option(ListValue, "xray_dns_mode", translate("Request protocol"))
o.default = "tcp"
o:value("tcp", "TCP")
o:value("udp", "UDP")
o:value("doh", "DoH")
o:depends("dns_mode", "xray")
o.cfgvalue = function(self, section)
return m:get(section, "v2ray_dns_mode")
end
o.write = function(self, section, value)
if s.fields["dns_mode"]:formvalue(section) == "xray" then
return m:set(section, "v2ray_dns_mode", value)
end
end
o = s:option(ListValue, "singbox_dns_mode", translate("Request protocol"))
o.default = "tcp"
o:value("tcp", "TCP")
o:value("udp", "UDP")
o:value("doh", "DoH")
o:value("http3", "HTTP3(DoH3)")
o:depends("dns_mode", "sing-box")
o.cfgvalue = function(self, section)
return m:get(section, "v2ray_dns_mode")
end
o.write = function(self, section, value)
if s.fields["dns_mode"]:formvalue(section) == "sing-box" then
return m:set(section, "v2ray_dns_mode", value)
end
end
---- DNS Forward
o = s:option(Value, "remote_dns", translate("Remote DNS"))
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({dns_mode = "dns2socks"})
o:depends({xray_dns_mode = "udp"})
o:depends({xray_dns_mode = "tcp"})
o:depends({singbox_dns_mode = "udp"})
o:depends({singbox_dns_mode = "tcp"})
o = s:option(Value, "remote_dns_doh", translate("Remote DNS DoH"))
o.description = translate("Format: URL[,IP] (optional IP to map the domain in the URL)")
o:value("https://1.1.1.1/dns-query", "1.1.1.1 (CloudFlare)")
o:value("https://1.1.1.2/dns-query", "1.1.1.2 (CloudFlare-Security)")
o:value("https://8.8.4.4/dns-query", "8.8.4.4 (Google)")
o:value("https://8.8.8.8/dns-query", "8.8.8.8 (Google)")
o:value("https://9.9.9.9/dns-query", "9.9.9.9 (Quad9)")
o:value("https://149.112.112.112/dns-query", "149.112.112.112 (Quad9)")
o:value("https://208.67.222.222/dns-query", "208.67.222.222 (OpenDNS)")
o:value("https://dns.adguard.com/dns-query,94.140.14.14", "94.140.14.14 (AdGuard)")
o:value("https://doh.libredns.gr/dns-query,116.202.176.26", "116.202.176.26 (LibreDNS)")
o:value("https://doh.libredns.gr/ads,116.202.176.26", "116.202.176.26 (LibreDNS-NoAds)")
o.default = o.keylist[1]
o.validate = function(self, value, t)
if value ~= "" then
value = api.trim(value)
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 api.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
o:depends({xray_dns_mode = "doh"})
o:depends({singbox_dns_mode = "doh"})
o:depends({singbox_dns_mode = "http3"})
o = s:option(Value, "remote_dns_client_ip", translate("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({dns_mode = "sing-box"})
o:depends({dns_mode = "xray"})
o:depends({_node_sel_shunt = "1"})
o = s:option(Flag, "remote_fakedns", "FakeDNS", translate("Use FakeDNS work in the domain that proxy."))
o.default = "0"
o.rmempty = false
o:depends({dns_mode = "sing-box"})
o:depends({dns_mode = "xray"})
o.validate = function(self, value, t)
if value and value == "1" then
local _dns_mode = s.fields["dns_mode"]:formvalue(t)
local _tcp_node = s.fields["tcp_node"]:formvalue(t)
if _dns_mode and _tcp_node then
if (m:get(_tcp_node, "type") or ""):lower() ~= _dns_mode and not _tcp_node:find("Socks_") then
return nil, translatef("TCP node must be '%s' type to use FakeDNS.", _dns_mode)
end
end
end
return value
end
o = s:option(ListValue, "chinadns_ng_default_tag", translate("Default DNS"))
o.default = "none"
o:value("gfw", translate("Remote DNS"))
o:value("chn", translate("Direct DNS"))
o:value("none", translate("Smart, Do not accept no-ip reply from Direct DNS"))
o:value("none_noip", translate("Smart, Accept no-ip reply from Direct DNS"))
local desc = "<ul>"
.. "<li>" .. translate("When not matching any domain name list:") .. "</li>"
.. "<li>" .. translate("Remote DNS: Can avoid more DNS leaks, but some domestic domain names maybe to proxy!") .. "</li>"
.. "<li>" .. translate("Direct DNS: Internet experience may be better, but DNS will be leaked!") .. "</li>"
o.description = desc
.. "<li>" .. translate("Smart: Forward to both direct and remote DNS, if the direct DNS resolution result is a mainland China IP, then use the direct result, otherwise use the remote result.") .. "</li>"
.. "<li>" .. translate("In smart mode, no-ip reply from Direct DNS:") .. "</li>"
.. "<li>" .. translate("Do not accept: Wait and use Remote DNS Reply.") .. "</li>"
.. "<li>" .. translate("Accept: Trust the Reply, using this option can improve DNS resolution speeds for some mainland IPv4-only sites.") .. "</li>"
.. "</ul>"
o:depends({dns_shunt = "chinadns-ng", tcp_proxy_mode = "proxy", chn_list = "direct"})
o = s:option(Flag, "force_https_soa", translate("Force HTTPS SOA"), translate("Force queries with qtype 65 to respond with an SOA record."))
o.default = "0"
o.rmempty = false
o:depends({dns_shunt = "chinadns-ng"})
o = s:option(ListValue, "use_default_dns", translate("Default DNS"))
o.default = "direct"
o:value("remote", translate("Remote DNS"))
o:value("direct", translate("Direct DNS"))
o.description = desc .. "</ul>"
o:depends({dns_shunt = "dnsmasq", tcp_proxy_mode = "proxy", chn_list = "direct"})
local tcp = s.fields["tcp_node"]
local udp = s.fields["udp_node"]
for k, v in pairs(socks_list) do
tcp:value(v.id, v["remark"])
tcp.group[#tcp.group+1] = (v.group and v.group ~= "") and v.group or translate("default")
udp:value(v.id, v["remark"])
udp.group[#udp.group+1] = (v.group and v.group ~= "") and v.group or translate("default")
end
for k, v in pairs(nodes_table) do
if #normal_list == 0 then
s.fields["dns_mode"]:depends({ _tcp_node_bool = "1" })
break
end
if v.protocol == "_shunt" then
if v.type == "Xray" and has_xray then
tcp:value(v.id, v["remark"])
tcp.group[#tcp.group+1] = (v.group and v.group ~= "") and v.group or translate("default")
udp:value(v.id, v["remark"])
udp.group[#udp.group+1] = (v.group and v.group ~= "") and v.group or translate("default")
s.fields["xray_dns_mode"]:depends({ _tcp_node_bool = "1", tcp_node = v.id })
s.fields["_node_sel_shunt"]:depends({ tcp_node = v.id })
end
if v.type == "sing-box" and has_singbox then
tcp:value(v.id, v["remark"])
tcp.group[#tcp.group+1] = (v.group and v.group ~= "") and v.group or translate("default")
udp:value(v.id, v["remark"])
udp.group[#udp.group+1] = (v.group and v.group ~= "") and v.group or translate("default")
s.fields["singbox_dns_mode"]:depends({ _tcp_node_bool = "1", tcp_node = v.id })
s.fields["_node_sel_shunt"]:depends({ tcp_node = v.id })
end
else
tcp:value(v.id, v["remark"])
tcp.group[#tcp.group+1] = (v.group and v.group ~= "") and v.group or translate("default")
udp:value(v.id, v["remark"])
udp.group[#udp.group+1] = (v.group and v.group ~= "") and v.group or translate("default")
end
end
return m
@@ -0,0 +1,32 @@
local api = require "luci.passwall.api"
local appname = "passwall"
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.passwall.com"
for _, k in ipairs(com.order) do
v = com[k]
if k ~= "chinadns-ng" then
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
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,842 @@
api = require "luci.passwall.api"
appname = "passwall"
datatypes = api.datatypes
local fs = api.fs
has_singbox = api.finded_com("sing-box")
has_xray = api.finded_com("xray")
local has_gfwlist = fs.access("/usr/share/passwall/rules/gfwlist")
local has_chnlist = fs.access("/usr/share/passwall/rules/chnlist")
local has_chnroute = fs.access("/usr/share/passwall/rules/chnroute")
m = Map(appname)
api.set_apply_on_parse(m)
m:append(Template(appname .. "/cbi/nodes_listvalue_com"))
local nodes_table = {}
for _, 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 _, 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 = {}
local tcp_socks_server = "127.0.0.1" .. ":" .. (m:get("@global[0]", "tcp_node_socks_port") or "1070")
local socks_table = {}
socks_table[#socks_table + 1] = {
id = tcp_socks_server,
remark = tcp_socks_server .. " - " .. translate("TCP Node")
}
m.uci:foreach(appname, "socks", function(s)
if s.enabled == "1" and s.node then
local id, remark
for k, n in pairs(nodes_table) do
if (s.node == n.id) then
remark = n["remark"]; break
end
end
id = "127.0.0.1" .. ":" .. s.port
socks_table[#socks_table + 1] = {
id = id,
remark = id .. " - " .. (remark or translate("Misconfigured"))
}
socks_list[#socks_list + 1] = {
id = "Socks_" .. s[".name"],
remark = translate("Socks Config") .. " " .. string.format("[%s %s]", s.port, translate("Port")),
group = "Socks"
}
end
end)
local doh_validate = function(self, value, t)
value = value:gsub("%s+", "")
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, translatef("%s request address","DoH") .. " " .. 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
---- TCP Node
o = s:taboption("Main", ListValue, "tcp_node", "<a style='color: red'>" .. translate("TCP Node") .. "</a>")
o.template = appname .. "/cbi/nodes_listvalue"
o:value("", translate("Close"))
o.group = {""}
---- UDP Node
o = s:taboption("Main", ListValue, "udp_node", "<a style='color: red'>" .. translate("UDP Node") .. "</a>")
o.template = appname .. "/cbi/nodes_listvalue"
o:value("", translate("Close"))
o:value("tcp", translate("Same as the tcp node"))
o.group = {"",""}
o:depends("_node_sel_other", "1")
o.remove = function(self, section)
local v = s.fields["shunt_udp_node"]:formvalue(section)
if not v then
return m:del(section, self.option)
end
end
o = s:taboption("Main", ListValue, "shunt_udp_node", "<a style='color: red'>" .. translate("UDP Node") .. "</a>")
o:value("close", translate("Close"))
o:value("tcp", translate("Same as the tcp node"))
o:depends("_node_sel_shunt", "1")
o.cfgvalue = function(self, section)
local v = m:get(section, "udp_node") or ""
if v == "" then v = "close" end
if v ~= "close" and v ~= "tcp" then v = "tcp" end
return v
end
o.write = function(self, section, value)
if value == "close" then value = "" end
return m:set(section, "udp_node", value)
end
-- Shunt Start
if (has_singbox or has_xray) and #nodes_table > 0 then
if #normal_list > 0 or #iface_list > 0 then
current_node_id = m.uci:get(appname, global_cfgid, "tcp_node")
current_node = current_node_id and m.uci:get_all(appname, current_node_id) or {}
if current_node.protocol == "_shunt" then
local shunt_lua = loadfile("/usr/lib/lua/luci/model/cbi/passwall/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["tcp_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({ tcp_node = "", ["!reverse"] = true })
for k, v in pairs(shunt_list) do
tips:depends("tcp_node", v.id)
end
for k, v in pairs(balancing_list) do
tips:depends("tcp_node", v.id)
end
end
end
o = s:taboption("Main", Value, "tcp_node_socks_port", translate("TCP Node") .. " Socks " .. translate("Listen Port"))
o.default = 1070
o.datatype = "port"
o:depends({ tcp_node = "", ["!reverse"] = true })
--[[
if has_singbox or has_xray then
o = s:taboption("Main", Value, "tcp_node_http_port", translate("TCP Node") .. " HTTP " .. translate("Listen Port") .. " " .. translate("0 is not use"))
o.default = 0
o.datatype = "port"
end
]]--
o = s:taboption("Main", Flag, "tcp_node_socks_bind_local", translate("TCP Node") .. " Socks " .. translate("Bind Local"), translate("When selected, it can only be accessed localhost."))
o.default = "1"
o:depends({ tcp_node = "", ["!reverse"] = true })
-- Node → DNS Depends Settings
o = s:taboption("Main", DummyValue, "_node_sel_shunt", "")
o.template = appname .. "/cbi/hidevalue"
o.value = "1"
o:depends({ tcp_node = "__always__" })
o = s:taboption("Main", DummyValue, "_node_sel_other", "")
o.template = appname .. "/cbi/hidevalue"
o.value = "1"
o:depends({ _node_sel_shunt = "1", ['!reverse'] = true })
-- [[ DNS Settings ]]--
s:tab("DNS", translate("DNS"))
o = s:taboption("DNS", ListValue, "dns_shunt", "DNS " .. translate("Shunt"))
o:value("dnsmasq", "Dnsmasq")
o:value("chinadns-ng", translate("ChinaDNS-NG (recommended)"))
if api.is_finded("smartdns") then
o:value("smartdns", "SmartDNS")
o.write = function(self, section, value)
if value ~= "smartdns" then
m:del(section, "group_domestic")
end
return ListValue.write(self, section, value)
end
o = s:taboption("DNS", Value, "group_domestic", translate("Domestic group name"))
o.placeholder = "local"
o.rmempty = false
o:depends("dns_shunt", "smartdns")
o.description = translate("You only need to configure domestic DNS packets in SmartDNS, and fill in the domestic DNS group name here.")
o.validate = function(self, value, section)
value = api.trim(value)
if value == "" then
return nil, translatef("%s cannot be empty.", "SmartDNS " .. translate("Domestic group name"))
end
return value
end
end
o = s:taboption("DNS", ListValue, "direct_dns_mode", translate("Direct DNS") .. " " .. translate("Request protocol"))
o:value("", translate("Auto"))
o:value("udp", translatef("Requery DNS By %s", "UDP"))
o:value("tcp", translatef("Requery DNS By %s", "TCP"))
o:depends({dns_shunt = "dnsmasq"})
o:depends({dns_shunt = "chinadns-ng"})
o = s:taboption("DNS", Value, "direct_dns", translate("Direct DNS"))
o.datatype = "or(ipaddr,ipaddrport)"
o.default = "223.5.5.5"
o:value("223.5.5.5")
o:value("223.6.6.6")
o:value("180.184.1.1")
o:value("180.184.2.2")
o:value("114.114.114.114")
o:value("114.114.115.115")
o:value("119.28.28.28")
o:depends("direct_dns_mode", "udp")
o:depends("direct_dns_mode", "tcp")
o = s:taboption("DNS", Flag, "filter_proxy_ipv6", translate("Filter Proxy Host IPv6"))
o.default = "0"
-- TCP分流时dns过滤模式保存逻辑
function dns_mode_save(section)
local f = s.fields["tcp_node"]
local id_val = f and f:formvalue(section) or ""
if id_val == "" then
return
end
for _, v in pairs(shunt_list) do
if v.id == id_val then
local type_val = v.type
if type_val and (type_val == "Xray" or type_val == "sing-box") then
local dns_shunt_val = s.fields["dns_shunt"]:formvalue(section)
local dns_mode_val = (dns_shunt_val ~= "smartdns") and "dns_mode" or "smartdns_dns_mode"
local current_val = m:get(section, dns_mode_val) or ""
local new_val = (type_val == "Xray") and "xray" or "sing-box"
if current_val ~= new_val then
m:set(section, dns_mode_val, new_val)
m:del(section, (dns_mode_val == "dns_mode") and "smartdns_dns_mode" or "dns_mode")
end
local dns_field = s.fields[type_val == "Xray" and "xray_dns_mode" or "singbox_dns_mode"]
local v2ray_dns_mode = dns_field and dns_field:formvalue(section)
if v2ray_dns_mode and m:get(section, "v2ray_dns_mode") ~= v2ray_dns_mode then
m:set(section, "v2ray_dns_mode", v2ray_dns_mode)
end
break
end
end
end
end
---- DNS Forward Mode
o = s:taboption("DNS", ListValue, "dns_mode", translate("Filter Mode"))
o.default = "tcp"
o:value("udp", translatef("Requery DNS By %s", "UDP"))
o:value("tcp", translatef("Requery DNS By %s", "TCP"))
if api.is_finded("dns2socks") then
o:value("dns2socks", "dns2socks")
end
if has_singbox then
o:value("sing-box", "Sing-Box")
end
if has_xray then
o:value("xray", "Xray")
end
o:depends({ dns_shunt = "chinadns-ng", _node_sel_other = "1" })
o:depends({ dns_shunt = "dnsmasq", _node_sel_other = "1" })
o.write = function(self, section, value)
if value ~= "sing-box" and value ~= "xray" then
m:del(section, "v2ray_dns_mode")
end
return ListValue.write(self, section, value)
end
o.remove = function(self, section)
local f = s.fields["smartdns_dns_mode"]
if f and f:formvalue(section) then
return m:del(section, self.option)
end
dns_mode_save(section)
end
---- SmartDNS Forward Mode
if api.is_finded("smartdns") then
o = s:taboption("DNS", ListValue, "smartdns_dns_mode", translate("Filter Mode"))
o:value("socks", "Socks")
if has_singbox then
o:value("sing-box", "Sing-Box")
end
if has_xray then
o:value("xray", "Xray")
end
o:depends({ dns_shunt = "smartdns", _node_sel_other = "1" })
o.write = function(self, section, value)
if value == "socks" then
m:del(section, "v2ray_dns_mode")
end
return ListValue.write(self, section, value)
end
o.remove = function(self, section)
local f = s.fields["dns_mode"]
if f and f:formvalue(section) then
return m:del(section, self.option)
end
dns_mode_save(section)
end
o = s:taboption("DNS", DynamicList, "smartdns_remote_dns", translate("Remote DNS"))
o:value("tcp://1.1.1.1")
o:value("tcp://8.8.4.4")
o:value("tcp://8.8.8.8")
o:value("tcp://9.9.9.9")
o:value("tcp://208.67.222.222")
o:value("tls://1.1.1.1")
o:value("tls://8.8.4.4")
o:value("tls://8.8.8.8")
o:value("tls://9.9.9.9")
o:value("tls://208.67.222.222")
o:value("https://1.1.1.1/dns-query")
o:value("https://8.8.4.4/dns-query")
o:value("https://8.8.8.8/dns-query")
o:value("https://9.9.9.9/dns-query")
o:value("https://208.67.222.222/dns-query")
o:value("https://dns.adguard.com/dns-query,94.140.14.14")
o:value("https://doh.libredns.gr/dns-query,116.202.176.26")
o:value("https://doh.libredns.gr/ads,116.202.176.26")
o:depends({ dns_shunt = "smartdns", smartdns_dns_mode = "socks" })
o.cfgvalue = function(self, section)
return m:get(section, self.option) or {"tcp://1.1.1.1"}
end
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
end
o = s:taboption("DNS", ListValue, "xray_dns_mode", translate("Remote DNS") .. " " .. translate("Request protocol"))
o.default = "tcp"
o:value("tcp", "TCP")
o:value("udp", "UDP")
o:value("doh", "DoH")
o:depends("dns_mode", "xray")
o:depends("smartdns_dns_mode", "xray")
o.cfgvalue = function(self, section)
return m:get(section, "v2ray_dns_mode")
end
o.write = function(self, section, value)
local f = s.fields["smartdns_dns_mode"]
if s.fields["dns_mode"]:formvalue(section) == "xray" or (f and f:formvalue(section) == "xray") then
return m:set(section, "v2ray_dns_mode", value)
end
end
o = s:taboption("DNS", ListValue, "singbox_dns_mode", translate("Remote DNS") .. " " .. translate("Request protocol"))
o.default = "tcp"
o:value("tcp", "TCP")
o:value("udp", "UDP")
o:value("doh", "DoH")
o:value("http3", "HTTP3(DoH3)")
o:depends("dns_mode", "sing-box")
o:depends("smartdns_dns_mode", "sing-box")
o.cfgvalue = function(self, section)
return m:get(section, "v2ray_dns_mode")
end
o.write = function(self, section, value)
local f = s.fields["smartdns_dns_mode"]
if s.fields["dns_mode"]:formvalue(section) == "sing-box" or (f and f:formvalue(section) == "sing-box") then
return m:set(section, "v2ray_dns_mode", value)
end
end
o = s:taboption("DNS", Value, "socks_server", translate("Socks Server"), translate("Make sure socks service is available on this address."))
for k, v in pairs(socks_table) do o:value(v.id, v.remark) end
o.default = socks_table[1].id
o.validate = function(self, value, t)
if not datatypes.ipaddrport(value) then
return nil, translate("Socks Server") .. " " .. translate("Not valid IP format, please re-enter!")
end
return value
end
o:depends({dns_mode = "dns2socks"})
---- DNS Forward
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)")
o:value("149.112.112.112", "149.112.112.112 (Quad9)")
o:value("208.67.220.220", "208.67.220.220 (OpenDNS)")
o:value("208.67.222.222", "208.67.222.222 (OpenDNS)")
o:depends({dns_mode = "dns2socks"})
o:depends({dns_mode = "tcp"})
o:depends({dns_mode = "udp"})
o:depends({xray_dns_mode = "udp"})
o:depends({xray_dns_mode = "tcp"})
o:depends({singbox_dns_mode = "udp"})
o:depends({singbox_dns_mode = "tcp"})
---- DoH
o = s:taboption("DNS", Value, "remote_dns_doh", translate("Remote DNS DoH"))
o.description = translate("Format: URL[,IP] (optional IP to map the domain in the URL)")
o.default = o.keylist[1]
o:value("https://1.1.1.1/dns-query", "1.1.1.1 (CloudFlare)")
o:value("https://1.1.1.2/dns-query", "1.1.1.2 (CloudFlare-Security)")
o:value("https://8.8.4.4/dns-query", "8.8.4.4 (Google)")
o:value("https://8.8.8.8/dns-query", "8.8.8.8 (Google)")
o:value("https://9.9.9.9/dns-query", "9.9.9.9 (Quad9)")
o:value("https://149.112.112.112/dns-query", "149.112.112.112 (Quad9)")
o:value("https://208.67.222.222/dns-query", "208.67.222.222 (OpenDNS)")
o:value("https://dns.adguard.com/dns-query,94.140.14.14", "94.140.14.14 (AdGuard)")
o:value("https://doh.libredns.gr/dns-query,116.202.176.26", "116.202.176.26 (LibreDNS)")
o:value("https://doh.libredns.gr/ads,116.202.176.26", "116.202.176.26 (LibreDNS-NoAds)")
o.validate = doh_validate
o:depends({xray_dns_mode = "doh"})
o:depends({singbox_dns_mode = "doh"})
o:depends({singbox_dns_mode = "http3"})
o = s:taboption("DNS", Value, "remote_dns_client_ip", translate("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({dns_mode = "sing-box"})
o:depends({dns_mode = "xray"})
o:depends("dns_shunt", "smartdns")
o:depends("_node_sel_shunt", "1")
o = s:taboption("DNS", Flag, "remote_fakedns", "FakeDNS", translate("Use FakeDNS work in the domain that proxy."))
o.default = "0"
o:depends({dns_mode = "sing-box", dns_shunt = "dnsmasq"})
o:depends({dns_mode = "sing-box", dns_shunt = "chinadns-ng"})
o:depends({smartdns_dns_mode = "sing-box", dns_shunt = "smartdns"})
o:depends({dns_mode = "xray", dns_shunt = "dnsmasq"})
o:depends({dns_mode = "xray", dns_shunt = "chinadns-ng"})
o:depends({smartdns_dns_mode = "xray", dns_shunt = "smartdns"})
--o:depends("_node_sel_shunt", "1")
o.validate = function(self, value, t)
if value and value == "1" then
local _dns_mode = s.fields["dns_mode"]:formvalue(t)
if not _dns_mode and s.fields["smartdns_dns_mode"] then
_dns_mode = s.fields["smartdns_dns_mode"]:formvalue(t)
end
local _tcp_node = s.fields["tcp_node"]:formvalue(t)
if _dns_mode and _tcp_node then
if (m:get(_tcp_node, "type") or ""):lower() ~= _dns_mode and not _tcp_node:find("Socks_") then
return nil, translatef("TCP node must be '%s' type to use FakeDNS.", _dns_mode)
end
end
end
return value
end
o = s:taboption("DNS", ListValue, "chinadns_ng_default_tag", translate("Default DNS"))
o.default = "none"
o:value("gfw", translate("Remote DNS"))
o:value("chn", translate("Direct DNS"))
o:value("none", translate("Smart, Do not accept no-ip reply from Direct DNS"))
o:value("none_noip", translate("Smart, Accept no-ip reply from Direct DNS"))
local desc = "<ul>"
.. "<li>" .. translate("When not matching any domain name list:") .. "</li>"
.. "<li>" .. translate("Remote DNS: Can avoid more DNS leaks, but some domestic domain names maybe to proxy!") .. "</li>"
.. "<li>" .. translate("Direct DNS: Internet experience may be better, but DNS will be leaked!") .. "</li>"
o.description = desc
.. "<li>" .. translate("Smart: Forward to both direct and remote DNS, if the direct DNS resolution result is a mainland China IP, then use the direct result, otherwise use the remote result.") .. "</li>"
.. "<li>" .. translate("In smart mode, no-ip reply from Direct DNS:") .. "</li>"
.. "<li>" .. translate("Do not accept: Wait and use Remote DNS Reply.") .. "</li>"
.. "<li>" .. translate("Accept: Trust the Reply, using this option can improve DNS resolution speeds for some mainland IPv4-only sites.") .. "</li>"
.. "</ul>"
o:depends({dns_shunt = "chinadns-ng", tcp_proxy_mode = "proxy", chn_list = "direct"})
o = s:taboption("DNS", ListValue, "use_default_dns", translate("Default DNS"))
o.default = "direct"
o:value("remote", translate("Remote DNS"))
o:value("direct", translate("Direct DNS"))
o.description = desc .. "</ul>"
o:depends({dns_shunt = "dnsmasq", tcp_proxy_mode = "proxy", chn_list = "direct"})
if api.is_finded("smartdns") then
o:depends({dns_shunt = "smartdns", tcp_proxy_mode = "proxy", chn_list = "direct"})
end
o = s:taboption("DNS", Flag, "force_https_soa", translate("Force HTTPS SOA"), translate("Force queries with qtype 65 to respond with an SOA record."))
o.default = "0"
o:depends({dns_shunt = "chinadns-ng"})
if api.is_finded("smartdns") then
o:depends({dns_shunt = "smartdns"})
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 on Reboot" or "Clear IPSET on Reboot")
o = s:taboption("DNS", Flag, "flush_set_on_reboot", set_title, translate("Clear IPSET/NFTSET on service reboot. This may increase reboot time."))
o.default = "0"
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("Proxy", translate("Mode"))
o = s:taboption("Proxy", Flag, "use_direct_list", translatef("Use %s", translate("Direct List")))
o.default = "1"
o = s:taboption("Proxy", Flag, "use_proxy_list", translatef("Use %s", translate("Proxy List")))
o.default = "1"
o = s:taboption("Proxy", Flag, "use_block_list", translatef("Use %s", translate("Block List")))
o.default = "1"
if has_gfwlist then
o = s:taboption("Proxy", Flag, "use_gfw_list", translatef("Use %s", translate("GFW List")))
o.default = "1"
end
if has_chnlist or has_chnroute then
o = s:taboption("Proxy", ListValue, "chn_list", translate("China List"))
o:value("0", translate("Close(Not use)"))
o:value("direct", translate("Direct Connection"))
o:value("proxy", translate("Proxy"))
o.default = "direct"
end
---- TCP Default Proxy Mode
o = s:taboption("Proxy", ListValue, "tcp_proxy_mode", "TCP " .. translate("Default Proxy Mode"))
o:value("disable", translate("No Proxy"))
o:value("proxy", translate("Proxy"))
o.default = "proxy"
---- UDP Default Proxy Mode
o = s:taboption("Proxy", ListValue, "udp_proxy_mode", "UDP " .. translate("Default Proxy Mode"))
o:value("disable", translate("No Proxy"))
o:value("proxy", translate("Proxy"))
o.default = "proxy"
o = s:taboption("Proxy", DummyValue, "switch_mode", " ")
o.template = appname .. "/global/proxy"
---- 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("Proxy", Flag, "localhost_proxy", translate("Localhost Proxy"), translate("When selected, localhost can transparent proxy."))
o.default = "1"
o.rmempty = false
o = s:taboption("Proxy", 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("Proxy", DummyValue, "localhost_proxy", translate("Localhost Proxy"))
o.rawhtml = true
function o.cfgvalue(self, section)
return html
end
o = s:taboption("Proxy", DummyValue, "client_proxy", translate("Client Proxy"))
o.rawhtml = true
function o.cfgvalue(self, section)
return html
end
end
o = s:taboption("Proxy", DummyValue, "_proxy_tips", " ")
o.rawhtml = true
o.cfgvalue = function(t, n)
return string.format('<a style="color: red" href="%s">%s</a>', api.url("acl"), translate("Want different devices to use different proxy modes/ports/nodes? Please use access control."))
end
s:tab("log", translate("Log"))
o = s:taboption("log", Flag, "log_tcp", translate("Enable") .. " " .. translatef("%s Node Log", "TCP"))
o.default = "0"
o.rmempty = false
o = s:taboption("log", Flag, "log_udp", translate("Enable") .. " " .. translatef("%s Node Log", "UDP"))
o.default = "0"
o.rmempty = false
o = s:taboption("log", ListValue, "loglevel", "Sing-Box/Xray " .. translate("Log Level"))
o.default = "warning"
o:value("debug")
o:value("info")
o:value("warning")
o:value("error")
o = s:taboption("log", Flag, "advanced_log_feature", translate("Advanced log feature"), translate("For professionals only."))
o.default = "0"
o = s:taboption("log", Flag, "sys_log", translate("Logging to system log"), translate("Logging to the system log for more advanced functions. For example, send logs to a dedicated log server."))
o:depends("advanced_log_feature", "1")
o.default = "0"
o = s:taboption("log", Value, "persist_log_path", translate("Persist log file directory"), translate("The path to the directory used to store persist log files, the \"/\" at the end can be omitted. Leave it blank to disable this feature."))
o:depends({ ["advanced_log_feature"] = 1, ["sys_log"] = 0 })
o = s:taboption("log", Value, "log_event_filter", translate("Log Event Filter"), translate("Support regular expression."))
o:depends("advanced_log_feature", "1")
o = s:taboption("log", Value, "log_event_cmd", translate("Shell Command"), translate("Shell command to execute, replace log content with %s."))
o:depends("advanced_log_feature", "1")
o = s:taboption("log", Flag, "log_chinadns_ng", translate("Enable") .. " ChinaDNS-NG " .. translate("Log"))
o.default = "0"
o.rmempty = false
o = s:taboption("log", DummyValue, "_log_tips", " ")
o.rawhtml = true
o.cfgvalue = function(t, n)
return string.format('<font color="red">%s</font>', translate("It is recommended to disable logging during regular use to reduce system overhead."))
end
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
function s2.remove(e, t)
local socks = "Socks_" .. t
local new_node = ""
local node0 = m:get("@nodes[0]") or nil
if node0 then
new_node = node0[".name"]
end
if (m:get("@global[0]", "tcp_node") or "") == socks then
m:set('@global[0]', "tcp_node", new_node)
end
if (m:get("@global[0]", "udp_node") or "") == socks then
m:set('@global[0]', "udp_node", new_node)
end
m.uci:foreach(appname, "acl_rule", function(s)
if s["tcp_node"] and s["tcp_node"] == socks then
m:set(s[".name"], "tcp_node", "default")
end
if s["udp_node"] and s["udp_node"] == socks then
m:set(s[".name"], "udp_node", "default")
end
end)
m.uci:foreach(appname, "nodes", function(s)
local list_name = s["urltest_node"] and "urltest_node" or (s["balancing_node"] and "balancing_node")
if list_name then
local nodes = m.uci:get_list(appname, s[".name"], list_name)
if nodes then
local changed = false
local new_nodes = {}
for _, node in ipairs(nodes) do
if node ~= socks then
table.insert(new_nodes, node)
else
changed = true
end
end
if changed then
m.uci:set_list(appname, s[".name"], list_name, new_nodes)
end
end
end
if s["fallback_node"] == socks then
m:del(s[".name"], "fallback_node")
end
end)
TypedSection.remove(e, 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"))
o.default = 0
o.datatype = "port"
end
local tcp = s.fields["tcp_node"]
local udp = s.fields["udp_node"]
local socks = s2.fields["node"]
for k, v in pairs(socks_list) do
tcp:value(v.id, v["remark"])
tcp.group[#tcp.group+1] = (v.group and v.group ~= "") and v.group or translate("default")
udp:value(v.id, v["remark"])
udp.group[#udp.group+1] = (v.group and v.group ~= "") and v.group or translate("default")
end
for k, v in pairs(nodes_table) do
if #normal_list == 0 and #iface_list == 0 then
break
end
if v.protocol == "_shunt" then
if has_singbox or has_xray then
tcp:value(v.id, v["remark"])
tcp.group[#tcp.group+1] = (v.group and v.group ~= "") and v.group or translate("default")
udp:value(v.id, v["remark"])
udp.group[#udp.group+1] = (v.group and v.group ~= "") and v.group or translate("default")
s.fields["_node_sel_shunt"]:depends({ tcp_node = v.id })
if m:get(v.id, "type") == "Xray" then
s.fields["xray_dns_mode"]:depends({ tcp_node = v.id })
else
s.fields["singbox_dns_mode"]:depends({ tcp_node = v.id })
end
end
else
tcp:value(v.id, v["remark"])
tcp.group[#tcp.group+1] = (v.group and v.group ~= "") and v.group or translate("default")
udp:value(v.id, v["remark"])
udp.group[#udp.group+1] = (v.group and v.group ~= "") and v.group or translate("default")
end
if v.type == "Socks" then
if has_singbox or has_xray then
socks:value(v.id, v["remark"])
socks.group[#socks.group+1] = (v.group and v.group ~= "") and v.group or translate("default")
end
else
socks:value(v.id, v["remark"])
socks.group[#socks.group+1] = (v.group and v.group ~= "") and v.group or translate("default")
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,181 @@
local api = require "luci.passwall.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, "HAPROXY " .. translate("Load Balancing"))
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: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.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 )<br>" ..
translate("Note: Frequent health checks increase CPU usage; an interval ≥ 60 seconds is recommended.")
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,190 @@
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."))
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 api = require "luci.passwall.api"
local appname = "passwall"
f = SimpleForm(appname)
f.reset = false
f.submit = false
f:append(Template(appname .. "/log/log"))
return f
@@ -0,0 +1,126 @@
api = require "luci.passwall.api"
appname = "passwall"
m = Map(appname, translate("Node Config"))
m.redirect = api.url("node_list")
api.set_apply_on_parse(m)
if not arg[1] or not m:get(arg[1]) then
luci.http.redirect(m.redirect)
end
fs = require "nixio.fs"
formvalue_key = "cbid." .. appname .. "." .. arg[1] .. "."
local header = Template(appname .. "/node_config/header")
header.api = api
header.config = m.config
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, "passwall", " ")
o.rawhtml = true
o.template = "passwall/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/passwall/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"))
if api.is_finded("ipt2socks") then
s.fields["type"]:value("Socks", translate("Socks"))
if s.val["type"] == "Socks" then
local function _n(name)
return "socks_" .. name
end
o = s:option(ListValue, _n("del_protocol"), " ") --始终隐藏,用于删除 protocol
o:depends({ [_n("__hide")] = "1" })
o.rewrite_option = "protocol"
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, "Socks", "socks_")
end
end
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.config = m.config
footer.section = arg[1]
m:append(footer)
return m
@@ -0,0 +1,35 @@
local api = require "luci.passwall.api"
local appname = "passwall"
local sys = api.sys
local datatypes = api.datatypes
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,228 @@
local api = require "luci.passwall.api"
local uci = api.uci
local appname = "passwall"
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_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)
if self.no_commit then
return
end
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)
m.no_commit = true
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,334 @@
local api = require "luci.passwall.api"
local uci = api.uci
local appname = "passwall"
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_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_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.") .. "<br>" .. string.format('<font color="red">%s</font>',
translate("Note: For node-specific DNS only. Keep Auto to avoid extra overhead."))
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("UseIPv4v6", translate("Prefer IPv4"))
o:value("UseIPv6v4", translate("Prefer IPv6"))
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
o = s:option(Value, "hysteria_up_mbps", "Hy/Hy2 " .. translate("Max upload Mbps"))
o.datatype = "uinteger"
o.default = "100"
o = s:option(Value, "hysteria_down_mbps", "Hy/Hy2 " .. translate("Max download Mbps"))
o.datatype = "uinteger"
o.default = "100"
end
o = s:option(Flag, "boot_update", translate("Update Once on Boot"), translate("Updates the subscription the first time PassWall 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 = "passwall"
o:value("passwall", "PassWall")
o:value("v2rayN/9.99", "v2rayN")
o:value("clash.meta", "Clash.Meta")
o:value("Clash", "Clash")
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 = 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"))
o:value("3", translate("Outbound Interface"))
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 = {}
o3 = s:option(Value, "outbound_iface", translate("Outbound Interface"))
o3:depends({ ["chain_proxy"] = "3" })
o3:value("", translate("All"))
local iface = api.get_network_devices()
for _, d in ipairs(iface) do
o3:value(d.name, d.label)
end
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,267 @@
local api = require "luci.passwall.api"
local appname = "passwall"
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 Proxy Drop Ports
o = s:option(Value, "tcp_proxy_drop_ports", translate("TCP Proxy Drop Ports"))
o.default = "disable"
o:value("disable", translate("No patterns are used"))
o.validate = port_validate
---- UDP Proxy Drop Ports
o = s:option(Value, "udp_proxy_drop_ports", translate("UDP Proxy Drop Ports"))
o.default = "443"
o:value("disable", translate("No patterns are used"))
o:value("443", translate("QUIC"))
o.validate = port_validate
---- TCP Redir Ports
o = s:option(Value, "tcp_redir_ports", translate("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", translate("Only Web"))
o.default = o.keylist[2]
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:value("53", "DNS")
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)
-- 禁止在隐藏时删除
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
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.")
local domains_excluded = string.format("/usr/share/%s/rules/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,176 @@
local api = require "luci.passwall.api"
local appname = "passwall"
local has_xray = api.finded_com("xray")
local has_singbox = api.finded_com("sing-box")
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(Flag, "adblock", translate("Enable adblock"))
o.rmempty = false
]]--
---- gfwlist URL
o = s:option(DynamicList, "gfwlist_url", translate("GFW domains(gfwlist) Update URL"))
o:depends("geo2rule", false)
o:value("https://cdn.jsdelivr.net/gh/YW5vbnltb3Vz/domain-list-community@release/gfwlist.txt", translate("v2fly/domain-list-community"))
o:value("https://cdn.jsdelivr.net/gh/Loyalsoldier/v2ray-rules-dat@release/gfw.txt", translate("Loyalsoldier/v2ray-rules-dat"))
o:value("https://cdn.jsdelivr.net/gh/Loukky/gfwlist-by-loukky/gfwlist.txt", translate("Loukky/gfwlist-by-loukky"))
o:value("https://cdn.jsdelivr.net/gh/gfwlist/gfwlist/gfwlist.txt", translate("gfwlist/gfwlist"))
o.default = o.keylist[2]
----chnroute URL
o = s:option(DynamicList, "chnroute_url", translate("China IPs(chnroute) Update URL"))
o:depends("geo2rule", false)
o:value("https://cdn.jsdelivr.net/gh/gaoyifan/china-operator-ip@ip-lists/china.txt", translate("gaoyifan/china-operator-ip/china"))
o:value("https://ispip.clang.cn/all_cn.txt", translate("Clang.CN"))
o:value("https://cdn.jsdelivr.net/gh/soffchen/GeoIP2-CN@release/CN-ip-cidr.txt", translate("soffchen/GeoIP2-CN"))
o:value("https://cdn.jsdelivr.net/gh/Hackl0us/GeoIP2-CN@release/CN-ip-cidr.txt", translate("Hackl0us/GeoIP2-CN"))
o:value("https://cdn.jsdelivr.net/gh/blackmatrix7/ios_rule_script@master/rule/Clash/ChinaMax/ChinaMax_IP_No_IPv6.txt", translate("ios_rule_script/ChinaMax_IP_No_IPv6"))
----chnroute6 URL
o = s:option(DynamicList, "chnroute6_url", translate("China IPv6s(chnroute6) Update URL"))
o:depends("geo2rule", false)
o:value("https://cdn.jsdelivr.net/gh/gaoyifan/china-operator-ip@ip-lists/china6.txt", translate("gaoyifan/china-operator-ip/china6"))
o:value("https://ispip.clang.cn/all_cn_ipv6.txt", translate("Clang.CN.IPv6"))
o:value("https://cdn.jsdelivr.net/gh/blackmatrix7/ios_rule_script@master/rule/Clash/ChinaMax/ChinaMax_IP.txt", translate("ios_rule_script/ChinaMax_IP"))
----chnlist URL
o = s:option(DynamicList, "chnlist_url", translate("China List(Chnlist) Update URL"))
o:depends("geo2rule", false)
o:value("https://cdn.jsdelivr.net/gh/felixonmars/dnsmasq-china-list/accelerated-domains.china.conf", translate("felixonmars/domains.china"))
o:value("https://cdn.jsdelivr.net/gh/felixonmars/dnsmasq-china-list/apple.china.conf", translate("felixonmars/apple.china"))
o:value("https://cdn.jsdelivr.net/gh/felixonmars/dnsmasq-china-list/google.china.conf", translate("felixonmars/google.china"))
o:value("https://cdn.jsdelivr.net/gh/Loyalsoldier/v2ray-rules-dat@release/china-list.txt", translate("Loyalsoldier/china-list"))
o:value("https://cdn.jsdelivr.net/gh/Loyalsoldier/v2ray-rules-dat@release/apple-cn.txt", translate("Loyalsoldier/apple-cn"))
o:value("https://cdn.jsdelivr.net/gh/Loyalsoldier/v2ray-rules-dat@release/google-cn.txt", translate("Loyalsoldier/google-cn"))
o:value("https://cdn.jsdelivr.net/gh/blackmatrix7/ios_rule_script@master/rule/Clash/ChinaMax/ChinaMax_Domain.txt", translate("ios_rule_script/ChinaMax_Domain"))
if has_xray or has_singbox then
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.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.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 = "/usr/share/v2ray/"
o.rmempty = false
if api.is_finded("geoview") then
o = s:option(Flag, "geo2rule", translate("Generate Rule List from Geo"))
o.default = 0
o.rmempty = false
o.description = translate("Generate rule lists such as GFW, China domains, and China IP ranges based on Geo files.") .. "<br><font color='red'>" ..
translate("When manually updating with this option enabled, rules will be regenerated from existing Geo files even if no new version is available.") .. "</font>"
o = s:option(Flag, "enable_geoview", translate("Enable Geo Data Parsing"))
o.default = 0
o.rmempty = false
o.description = "<ul>"
.. "<li>" .. translate("Experimental feature.") .. "</li>"
.. "<li>" .. "1." .. translate("Analyzes and preloads GeoIP/Geosite data to enhance the shunt performance of Sing-box/Xray.") .. "</li>"
.. "<li>" .. "2." .. translate("Once enabled, the rule list can support GeoIP/Geosite rules.") .. "</li>"
.. "<li>" .. translate("Note: Increases resource usage; Geosite analysis is only supported in ChinaDNS-NG and SmartDNS modes.") .. "</li>"
.. "</ul>"
function o.write(self, section, value)
local old = m:get(section, self.option) or "0"
if old ~= value then
m:set("@global[0]", "flush_set", "1")
end
return Flag.write(self, section, value)
end
end
end
---- 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
---- 更新选项,始终被js隐藏
local flags = {
"gfwlist_update", "chnroute_update", "chnroute6_update",
"chnlist_update", "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"))
if has_xray or has_singbox then
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"))
end
return m
@@ -0,0 +1,338 @@
local api = require "luci.passwall.api"
local appname = "passwall"
local fs = api.fs
local sys = api.sys
local uci = api.uci
local datatypes = api.datatypes
local path = string.format("/usr/share/%s/rules/", appname)
local gfwlist_path = "/usr/share/passwall/rules/gfwlist"
local chnlist_path = "/usr/share/passwall/rules/chnlist"
local chnroute_path = "/usr/share/passwall/rules/chnroute"
m = Map(appname)
api.set_apply_on_parse(m)
function clean_text(text)
local nbsp = string.char(0xC2, 0xA0) -- 不间断空格(U+00A0)
local fullwidth_space = string.char(0xE3, 0x80, 0x80) -- 全角空格(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
-- [[ Rule List Settings ]]--
s = m:section(TypedSection, "global_rules")
s.anonymous = true
s:tab("direct_list", translate("Direct List"))
s:tab("proxy_list", translate("Proxy List"))
s:tab("block_list", translate("Block List"))
s:tab("lan_ip_list", translate("Lan IP List"))
s:tab("route_hosts", translate("Route Hosts"))
---- Direct Hosts
local direct_host = path .. "direct_host"
o = s:taboption("direct_list", TextValue, "direct_host", "", "<font color='red'>" .. translate("Join the direct hosts list of domain names will not proxy.") .. "</font>")
o.rows = 15
o.wrap = "off"
o.cfgvalue = function(self, section)
return fs.readfile(direct_host) or ""
end
o.write = function(self, section, value)
fs.writefile(direct_host, value:gsub("\r\n", "\n"))
sys.call("rm -rf /tmp/etc/passwall_tmp/dns_*")
end
o.remove = function(self, section, value)
fs.writefile(direct_host, "")
sys.call("rm -rf /tmp/etc/passwall_tmp/dns_*")
end
o.validate = function(self, value)
local hosts= {}
value = clean_text(value)
string.gsub(value, '[^' .. "\r\n" .. ']+', function(w) table.insert(hosts, api.trim(w)) end)
for index, host in ipairs(hosts) do
if host ~= "" and not host:find("^#") and not host:find("^geosite:") then
if not datatypes.hostname(host) then
return nil, host .. " " .. translate("Not valid domain name, please re-enter!")
end
end
end
return value
end
---- Direct IP
local direct_ip = path .. "direct_ip"
o = s:taboption("direct_list", TextValue, "direct_ip", "", "<font color='red'>" .. translate("These had been joined ip addresses will not proxy. Please input the ip address or ip address segment,every line can input only one ip address. For example: 192.168.0.0/24 or 223.5.5.5.") .. "</font>")
o.rows = 15
o.wrap = "off"
o.cfgvalue = function(self, section)
return fs.readfile(direct_ip) or ""
end
o.write = function(self, section, value)
fs.writefile(direct_ip, value:gsub("\r\n", "\n"))
end
o.remove = function(self, section, value)
fs.writefile(direct_ip, "")
end
o.validate = function(self, value)
local ipmasks= {}
value = clean_text(value)
string.gsub(value, '[^' .. "\r\n" .. ']+', function(w) table.insert(ipmasks, api.trim(w)) end)
for index, ipmask in ipairs(ipmasks) do
if ipmask ~= "" and not ipmask:find("^#") and not ipmask:find("^geoip:") then
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
---- Proxy Hosts
local proxy_host = path .. "proxy_host"
o = s:taboption("proxy_list", TextValue, "proxy_host", "", "<font color='red'>" .. translate("These had been joined websites will use proxy. Please input the domain names of websites, every line can input only one website domain. For example: google.com.") .. "</font>")
o.rows = 15
o.wrap = "off"
o.cfgvalue = function(self, section)
return fs.readfile(proxy_host) or ""
end
o.write = function(self, section, value)
fs.writefile(proxy_host, value:gsub("\r\n", "\n"))
sys.call("rm -rf /tmp/etc/passwall_tmp/dns_*")
end
o.remove = function(self, section, value)
fs.writefile(proxy_host, "")
sys.call("rm -rf /tmp/etc/passwall_tmp/dns_*")
end
o.validate = function(self, value)
local hosts= {}
value = clean_text(value)
string.gsub(value, '[^' .. "\r\n" .. ']+', function(w) table.insert(hosts, api.trim(w)) end)
for index, host in ipairs(hosts) do
if host ~= "" and not host:find("^#") and not host:find("^geosite:") then
if not datatypes.hostname(host) then
return nil, host .. " " .. translate("Not valid domain name, please re-enter!")
end
end
end
return value
end
---- Proxy IP
local proxy_ip = path .. "proxy_ip"
o = s:taboption("proxy_list", TextValue, "proxy_ip", "", "<font color='red'>" .. translate("These had been joined ip addresses will use proxy. Please input the ip address or ip address segment, every line can input only one ip address. For example: 35.24.0.0/24 or 8.8.4.4.") .. "</font>")
o.rows = 15
o.wrap = "off"
o.cfgvalue = function(self, section)
return fs.readfile(proxy_ip) or ""
end
o.write = function(self, section, value)
fs.writefile(proxy_ip, value:gsub("\r\n", "\n"))
end
o.remove = function(self, section, value)
fs.writefile(proxy_ip, "")
end
o.validate = function(self, value)
local ipmasks= {}
value = clean_text(value)
string.gsub(value, '[^' .. "\r\n" .. ']+', function(w) table.insert(ipmasks, api.trim(w)) end)
for index, ipmask in ipairs(ipmasks) do
if ipmask ~= "" and not ipmask:find("^#") and not ipmask:find("^geoip:") then
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
---- Block Hosts
local block_host = path .. "block_host"
o = s:taboption("block_list", TextValue, "block_host", "", "<font color='red'>" .. translate("These had been joined websites will be block. Please input the domain names of websites, every line can input only one website domain. For example: twitter.com.") .. "</font>")
o.rows = 15
o.wrap = "off"
o.cfgvalue = function(self, section)
return fs.readfile(block_host) or ""
end
o.write = function(self, section, value)
fs.writefile(block_host, value:gsub("\r\n", "\n"))
end
o.remove = function(self, section, value)
fs.writefile(block_host, "")
end
o.validate = function(self, value)
local hosts= {}
value = clean_text(value)
string.gsub(value, '[^' .. "\r\n" .. ']+', function(w) table.insert(hosts, api.trim(w)) end)
for index, host in ipairs(hosts) do
if host ~= "" and not host:find("^#") and not host:find("^geosite:") then
if not datatypes.hostname(host) then
return nil, host .. " " .. translate("Not valid domain name, please re-enter!")
end
end
end
return value
end
---- Block IP
local block_ip = path .. "block_ip"
o = s:taboption("block_list", TextValue, "block_ip", "", "<font color='red'>" .. translate("These had been joined ip addresses will be block. Please input the ip address or ip address segment, every line can input only one ip address.") .. "</font>")
o.rows = 15
o.wrap = "off"
o.cfgvalue = function(self, section)
return fs.readfile(block_ip) or ""
end
o.write = function(self, section, value)
fs.writefile(block_ip, value:gsub("\r\n", "\n"))
end
o.remove = function(self, section, value)
fs.writefile(block_ip, "")
end
o.validate = function(self, value)
local ipmasks= {}
value = clean_text(value)
string.gsub(value, '[^' .. "\r\n" .. ']+', function(w) table.insert(ipmasks, api.trim(w)) end)
for index, ipmask in ipairs(ipmasks) do
if ipmask ~= "" and not ipmask:find("^#") and not ipmask:find("^geoip:") then
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
---- Lan IPv4
local lanlist_ipv4 = path .. "lanlist_ipv4"
o = s:taboption("lan_ip_list", TextValue, "lanlist_ipv4", "", "<font color='red'>" .. translate("The list is the IPv4 LAN IP list, which represents the direct connection IP of the LAN. If you need the LAN IP in the proxy list, please clear it from the list. Do not modify this list by default.") .. "</font>")
o.rows = 15
o.wrap = "off"
o.cfgvalue = function(self, section)
return fs.readfile(lanlist_ipv4) or ""
end
o.write = function(self, section, value)
fs.writefile(lanlist_ipv4, value:gsub("\r\n", "\n"))
end
o.remove = function(self, section, value)
fs.writefile(lanlist_ipv4, "")
end
o.validate = function(self, value)
local ipmasks= {}
value = clean_text(value)
string.gsub(value, '[^' .. "\r\n" .. ']+', function(w) table.insert(ipmasks, api.trim(w)) end)
for index, ipmask in ipairs(ipmasks) do
if ipmask ~= "" and not ipmask:find("^#") then
if not datatypes.ipmask4(ipmask) then
return nil, ipmask .. " " .. translate("Not valid IPv4 format, please re-enter!")
end
end
end
return value
end
---- Lan IPv6
local lanlist_ipv6 = path .. "lanlist_ipv6"
o = s:taboption("lan_ip_list", TextValue, "lanlist_ipv6", "", "<font color='red'>" .. translate("The list is the IPv6 LAN IP list, which represents the direct connection IP of the LAN. If you need the LAN IP in the proxy list, please clear it from the list. Do not modify this list by default.") .. "</font>")
o.rows = 15
o.wrap = "off"
o.cfgvalue = function(self, section)
return fs.readfile(lanlist_ipv6) or ""
end
o.write = function(self, section, value)
fs.writefile(lanlist_ipv6, value:gsub("\r\n", "\n"))
end
o.remove = function(self, section, value)
fs.writefile(lanlist_ipv6, "")
end
o.validate = function(self, value)
local ipmasks= {}
value = clean_text(value)
string.gsub(value, '[^' .. "\r\n" .. ']+', function(w) table.insert(ipmasks, api.trim(w)) end)
for index, ipmask in ipairs(ipmasks) do
if ipmask ~= "" and not ipmask:find("^#") then
if not datatypes.ipmask6(ipmask) then
return nil, ipmask .. " " .. translate("Not valid IPv6 format, please re-enter!")
end
end
end
return value
end
---- Route Hosts
local hosts = "/etc/hosts"
o = s:taboption("route_hosts", TextValue, "hosts", "", "<font color='red'>" .. translate("Configure routing etc/hosts file, if you don't know what you are doing, please don't change the content.") .. "</font>")
o.rows = 15
o.wrap = "off"
o.cfgvalue = function(self, section)
return fs.readfile(hosts) or ""
end
o.write = function(self, section, value)
fs.writefile(hosts, clean_text(value))
end
o.remove = function(self, section, value)
fs.writefile(hosts, "")
end
if fs.access(gfwlist_path) then
s:tab("gfw_list", translate("GFW List"))
o = s:taboption("gfw_list", DummyValue, "_gfw_fieldset")
o.rawhtml = true
o.default = string.format([[
<div style="display: flex; align-items: center;">
<input class="btn cbi-button cbi-button-add" type="button" onclick="read_gfw()" value="%s" />
<label id="gfw_total_lines" style="margin-left: auto; margin-right: 10px;"></label>
</div>
<textarea id="gfw_textarea" class="cbi-input-textarea" style="width: 100%%; margin-top: 10px;" rows="40" wrap="off" readonly="readonly"></textarea>
]], translate("Read List"))
end
if fs.access(chnlist_path) then
s:tab("chn_list", translate("China List") .. "(" .. translate("Domain") .. ")")
o = s:taboption("chn_list", DummyValue, "_chn_fieldset")
o.rawhtml = true
o.default = string.format([[
<div style="display: flex; align-items: center;">
<input class="btn cbi-button cbi-button-add" type="button" onclick="read_chn()" value="%s" />
<label id="chn_total_lines" style="margin-left: auto; margin-right: 10px;"></label>
</div>
<textarea id="chn_textarea" class="cbi-input-textarea" style="width: 100%%; margin-top: 10px;" rows="40" wrap="off" readonly="readonly"></textarea>
]], translate("Read List"))
end
if fs.access(chnroute_path) then
s:tab("chnroute_list", translate("China List") .. "(IP)")
o = s:taboption("chnroute_list", DummyValue, "_chnroute_fieldset")
o.rawhtml = true
o.default = string.format([[
<div style="display: flex; align-items: center;">
<input class="btn cbi-button cbi-button-add" type="button" onclick="read_chnroute()" value="%s" />
<label id="chnroute_total_lines" style="margin-left: auto; margin-right: 10px;"></label>
</div>
<textarea id="chnroute_textarea" class="cbi-input-textarea" style="width: 100%%; margin-top: 10px;" rows="40" wrap="off" readonly="readonly"></textarea>
]], translate("Read List"))
end
m:append(Template(appname .. "/rule_list/js"))
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 api.finded_com("geoview") and fs.access(geosite_path) and fs.access(geoip_path) then
if api.compare_versions(api.get_app_version("geoview"), ">=", "0.1.0") then
s:tab("geoview", translate("Geo View"))
o = s:taboption("geoview", DummyValue, "_geoview_fieldset")
o.rawhtml = true
o.template = appname .. "/rule_list/geoview"
end
end
m.on_before_save = function(self)
m:set("@global[0]", "flush_set", "1")
end
return m
@@ -0,0 +1,248 @@
local api = require "luci.passwall.api"
local appname = "passwall"
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
m.on_before_save = function(self)
m:set("@global[0]", "flush_set", "1")
end
-- Add inline CSS to map description
m.description = (m.description or "") .. "\n" .. [[
<style>
div[id^="cbid.passwall."] .cbi-value-field {
display: flex;
flex-wrap: wrap;
gap: 1em;
}
div[id^="cbid.passwall."] .cbi-checkbox {
display: inline-flex;
align-items: center;
}
</style>
]]
function clean_text(text)
local nbsp = string.char(0xC2, 0xA0) -- 不间断空格(U+00A0)
local fullwidth_space = string.char(0xE3, 0x80, 0x80) -- 全角空格(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
o = s:option(MultiValue, "inbound", translate("Inbound Tag"))
o:value("tproxy", translate("Transparent proxy"))
o:value("socks", "Socks")
o.widget = "checkbox"
o.default = nil
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 is compatible with Geo rules and rule-set. rule-set begin 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 is compatible with Geo rules and rule-set. rule-set begin 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,139 @@
local api = require "luci.passwall.api"
local appname = "passwall"
m = Map(appname)
m.redirect = api.url()
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_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,109 @@
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(ListValue, _n("protocol"), translate("Protocol"))
o:value("udp", "UDP")
o = s:option(Value, _n("address"), translate("Address (Support Domain Name)"))
o:depends({ [_n("realms")] = false })
o = s:option(Value, _n("port"), translate("Port"))
o.datatype = "port"
o:depends({ [_n("realms")] = false })
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:depends({ [_n("realms")] = false })
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:depends({ [_n("realms")] = false })
o = s:option(Flag, _n("realms"), translate("Realms"))
o.default = "0"
o.rewrite_option = o.option
o = s:option(Value, _n("realm_url"), translate("Realm URL"), translate("Example:") .. "realm://public@realm.hy2.io/your-realm-name")
o.rewrite_option = o.option
o:depends({ [_n("realms")] = "1" })
o = s:option(DynamicList, _n("realm_stun"), translate("Realm STUN"))
o.default = { "stun.sip.us:3478", "stun.nextcloud.com:3478", "global.stun.twilio.com:3478" }
o.rewrite_option = o.option
o:depends({ [_n("realms")] = "1" })
o = s:option(Value, _n("auth_password"), translate("Auth Password"))
o.password = true
o.rewrite_option = o.option
o = s:option(ListValue, _n("obfs_type"), translate("Obfs Type"))
o:value("", translate("Disable"))
o:value("salamander")
o:value("gecko")
o.rewrite_option = o.option
o = s:option(Value, _n("obfs_password"), translate("Obfs Password"))
o.rewrite_option = o.option
o:depends({ [_n("obfs_type")] = "salamander" })
o:depends({ [_n("obfs_type")] = "gecko" })
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,860 @@
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 netdev_list = api.get_network_devices()
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
-- 读取旧 DynamicList
function o.cfgvalue(self, section)
return m.uci:get_list(appname, section, "balancing_node") or {}
end
-- 写入保持 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) .. string.format("<br><font color='red'>%s</font>",
translate("Keep the match scope small. Too many nodes can impact router performance."))
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.group = {"",""}
o:value("", translate("Close(Not use)"))
o:value("_direct", translate("Direct Connection"))
o:depends({ [_n("protocol")] = "_balancing" })
o.template = appname .. "/cbi/nodes_listvalue"
-- 最大 fallback 套娃层数
local MAX_FALLBACK_DEPTH = 3
-- 检查是否会形成回环
local function will_loop(start_id, target_id, depth)
depth = depth or 0
-- 超过最大深度后停止递归
if depth >= MAX_FALLBACK_DEPTH then
return false
end
for _, v in ipairs(fallback_list) do
if v.id == target_id then
local fb = v.fallback
-- 没有 fallback
if not fb or fb == "" or fb == "_direct" then
return false
end
-- 检测到回环
if fb == start_id then
return true
end
-- 继续递归检查
return will_loop(start_id, fb, depth + 1)
end
end
return false
end
-- 获取 fallback 链深度
local function get_fallback_depth(id, depth)
depth = depth or 0
if depth >= MAX_FALLBACK_DEPTH then
return depth
end
for _, v in ipairs(fallback_list) do
if v.id == id then
local fb = v.fallback
if not fb or fb == "" or fb == "_direct" then
return depth
end
return get_fallback_depth(fb, depth + 1)
end
end
return depth
end
for _, v in ipairs(fallback_list) do
local depth = get_fallback_depth(v.id)
-- 超过最大套娃层数后,不允许继续选择 balancer
if depth < MAX_FALLBACK_DEPTH
and not will_loop(arg[1], v.id)
then
o:value(v.id, v.remark)
o.group[#o.group + 1] = (v.group and v.group ~= "") and v.group or translate("default")
end
end
for k1, v1 in pairs(node_list) do
if k1 == "socks_list" or k1 == "normal_list" or k1 == "urltest_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 = o.keylist[3]
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.")
o = s:option(Value, _n("tolerance"), translate("Failure Tolerance (%)"))
o:depends({ [_n("balancingStrategy")] = "leastLoad" })
o.datatype = "uinteger"
o.default = "10"
o.placeholder = "10"
o.description = translate("The maximum acceptable speed test failure rate. For example, 1 means allowing a 1% failure rate.")
end -- [[ 负载均衡 End ]]
if load_iface_options then -- [[ 自定义接口 Start ]]
o = s:option(Value, _n("iface"), translate("Interface"))
o:depends({ [_n("protocol")] = "_iface" })
for _, d in ipairs(netdev_list) do
o:value(d.name, d.label)
end
end -- [[ 自定义接口 End ]]
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_auth_password"), translate("Auth Password"))
o.password = true
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_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(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"), translate("Only recommend to use with VLESS-TCP-XTLS-Vision."))
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.description = translate("Once set, connects only when the server’s chain fingerprint matches.") ..
string.format("<a href='javascript:void(0)' onclick='javascript:fetchCertSha256(this)'>%s</a>", "→ " .. translate("Fetch Manually"))
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部分 ]]--
-- TCP伪装
o = s:option(ListValue, _n("tcp_guise"), translate("Camouflage Type"))
o:value("none", "none")
o:value("http", "http")
o:depends({ [_n("transport")] = "raw" })
-- HTTP域名
o = s:option(DynamicList, _n("tcp_guise_http_host"), translate("HTTP Host"))
o:depends({ [_n("tcp_guise")] = "http" })
-- 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"))
o.description = translate("If the node address is a domain name, this DNS will be used for resolution.") .. "<br>" .. string.format('<font color="red">%s</font>',
translate("Note: For node-specific DNS only. Keep Auto to avoid extra overhead."))
o:value("", translate("Auto"))
o:value("tcp", "TCP")
o:value("udp", "UDP")
o:value("https", "DoH")
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("UseIPv4v6", translate("Prefer IPv4"))
o:value("UseIPv6v4", translate("Prefer IPv6"))
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"))
o:value("3", translate("Outbound Interface"))
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 = {}
o3 = s:option(Value, _n("outbound_iface"), translate("Outbound Interface"))
o3:depends({ [_n("chain_proxy")] = "3" })
o3:value("", translate("All"))
for _, d in ipairs(netdev_list) do
o3:value(d.name, d.label)
end
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/passwall/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,880 @@
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}'")
local singbox_version = api.get_app_version("sing-box"):match("[^v]+")
o = s:option(ListValue, _n("protocol"), translate("Protocol"))
o:value("socks", "Socks")
o:value("http", "HTTP")
o:value("shadowsocks", "Shadowsocks")
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 netdev_list = api.get_network_devices()
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
-- 读取旧 DynamicList
function o.cfgvalue(self, section)
return m.uci:get_list(appname, section, "urltest_node") or {}
end
-- 写入保持 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) .. string.format("<br><font color='red'>%s</font>",
translate("Keep the match scope small. Too many nodes can impact router performance."))
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 = o.keylist[3]
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 -- [[ 自定义接口 Start ]]
o = s:option(Value, _n("iface"), translate("Interface"))
o:depends({ [_n("protocol")] = "_iface" })
for _, d in ipairs(netdev_list) do
o:value(d.name, d.label)
end
end
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")] = "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" })
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", [_n("hysteria2_realms")] = false })
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", [_n("hysteria2_realms")] = false })
o = s:option(Flag, _n("hysteria2_realms"), translate("Realms"))
o.default = "0"
if api.compare_versions(singbox_version, ">=", "1.14.0") then
o:depends({ [_n("protocol")] = "hysteria2"})
else
o:depends({ [_n("protocol")] = "__hide"})
end
o = s:option(Value, _n("hysteria2_realm_url"), translate("Realm URL"), translate("Example:") .. "realm://public@realm.hy2.io/your-realm-name")
o:depends({ [_n("hysteria2_realms")] = "1" })
o = s:option(DynamicList, _n("hysteria2_realm_stun"), translate("Realm STUN"))
o.default = { "stun.sip.us:3478", "stun.nextcloud.com:3478", "global.stun.twilio.com:3478" }
o:depends({ [_n("hysteria2_realms")] = "1" })
o = s:option(Value, _n("hysteria2_auth_password"), translate("Auth Password"))
o.password = true
o:depends({ [_n("protocol")] = "hysteria2"})
o = s:option(ListValue, _n("hysteria2_obfs_type"), translate("Obfs Type"))
o:value("", translate("Disable"))
o:value("salamander")
o:value("gecko")
o:depends({ [_n("protocol")] = "hysteria2" })
o = s:option(Value, _n("hysteria2_obfs_password"), translate("Obfs Password"))
o:depends({ [_n("hysteria2_obfs_type")] = "salamander" })
o:depends({ [_n("hysteria2_obfs_type")] = "gecko" })
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(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"})
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", [_n("hysteria2_realms")] = false })
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(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"))
o.description = translate("If the node address is a domain name, this DNS will be used for resolution.") .. "<br>" .. string.format('<font color="red">%s</font>',
translate("Note: For node-specific DNS only. Keep Auto to avoid extra overhead."))
o:value("", translate("Auto"))
o:value("tcp", "TCP")
o:value("udp", "UDP")
o:value("https", "DoH")
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
local depends_condition = { [_n("protocol")] = v }
if v == "hysteria2" then
depends_condition[_n("hysteria2_realms")] = false
end
s.fields[_n("address")]:depends(depends_condition)
s.fields[_n("port")]:depends(depends_condition)
s.fields[_n("domain_resolver")]:depends(depends_condition)
s.fields[_n("domain_strategy")]:depends(depends_condition)
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"))
o:value("3", translate("Outbound Interface"))
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", [_n("hysteria2_realms")] = false })
o1.template = appname .. "/cbi/nodes_listvalue"
o1.group = {}
o3 = s:option(Value, _n("outbound_iface"), translate("Outbound Interface"))
o3:depends({ [_n("chain_proxy")] = "3" })
o3:value("", translate("All"))
for _, d in ipairs(netdev_list) do
o3:value(d.name, d.label)
end
o2 = s:option(ListValue, _n("to_node"), translate("Landing Node"), translate("Only support a layer of proxy."))
o2:depends({ [_n("chain_proxy")] = "2", [_n("hysteria2_realms")] = false })
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/passwall/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,76 @@
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-chacha20-poly1305"
}
o = s:option(ListValue, _n("del_protocol")) --始终隐藏,用于删除 protocol
o:depends({ [_n("__hide")] = "1" })
o.rewrite_option = "protocol"
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,70 @@
local m, s = ...
if not api.is_finded("ssr-local") and not api.is_finded("ssr-redir")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,91 @@
local api = require "luci.passwall.api"
m = Map("passwall_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("passwall/server/log"))
m:append(Template("passwall/server/users_list_status"))
return m
@@ -0,0 +1,135 @@
local m, s = ...
if not api.finded_com("hysteria") then
return
end
local fs = api.fs
local type_name = "Hysteria2"
-- [[ Hysteria2 ]]
s.fields["type"]:value(type_name, "Hysteria2")
if s.val["type"] and s.val["type"] ~= type_name then
return
end
local option_prefix = "hysteria2_"
local function _n(name)
return option_prefix .. name
end
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(Flag, _n("realms"), translate("Realms"))
o.default = "0"
o.rewrite_option = o.option
o:depends({ [_n("custom")] = false })
o = s:option(Value, _n("realm_url"), translate("Realm URL"), translate("Example:") .. "realm://public@realm.hy2.io/your-realm-name")
o.rewrite_option = o.option
o:depends({ [_n("realms")] = "1" })
o = s:option(DynamicList, _n("realm_stun"), translate("Realm STUN"))
o.default = { "stun.sip.us:3478", "stun.nextcloud.com:3478", "global.stun.twilio.com:3478" }
o.rewrite_option = o.option
o:depends({ [_n("realms")] = "1" })
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(ListValue, _n("obfs_type"), translate("Obfs Type"))
o:value("", translate("Disable"))
o:value("salamander")
o:value("gecko")
o.rewrite_option = o.option
o:depends({ [_n("custom")] = false })
o = s:option(Value, _n("obfs_password"), translate("Obfs Password"))
o.rewrite_option = o.option
o:depends({ [_n("obfs_type")] = "salamander" })
o:depends({ [_n("obfs_type")] = "gecko" })
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 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 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,528 @@
local m, s = ...
if not api.finded_com("xray") then
return
end
local 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"] and s.val["type"] ~= type_name then
return
end
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"
}
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(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(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 })
---- [[ 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 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 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" })
-- [[ TCP部分 ]]--
-- TCP伪装
o = s:option(ListValue, _n("tcp_guise"), translate("Camouflage Type"))
o:value("none", "none")
o:value("http", "http")
o:depends({ [_n("transport")] = "raw" })
-- HTTP域名
o = s:option(DynamicList, _n("tcp_guise_http_host"), translate("HTTP Host"))
o:depends({ [_n("tcp_guise")] = "http" })
-- 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:depends({ [_n("outbound_node")] = "_iface"})
local netdev_list = api.get_network_devices()
for _, d in ipairs(netdev_list) do
o:value(d.name, d.label)
end
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,507 @@
local m, s = ...
local singbox_bin = api.finded_com("sing-box")
if not singbox_bin then
return
end
local 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"] and s.val["type"] ~= type_name then
return
end
local option_prefix = "singbox_"
local function _n(name)
return option_prefix .. name
end
local singbox_tags = luci.sys.exec(singbox_bin .. " version | grep 'Tags:' | awk '{print $2}'")
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"
}
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(Flag, _n("hysteria2_realms"), translate("Realms"))
o.default = "0"
o:depends({ [_n("protocol")] = "hysteria2"})
o = s:option(Value, _n("hysteria2_realm_url"), translate("Realm URL"), translate("Example:") .. "realm://public@realm.hy2.io/your-realm-name")
o:depends({ [_n("hysteria2_realms")] = "1" })
o = s:option(DynamicList, _n("hysteria2_realm_stun"), translate("Realm STUN"))
o.default = { "stun.sip.us:3478", "stun.nextcloud.com:3478", "global.stun.twilio.com:3478" }
o:depends({ [_n("hysteria2_realms")] = "1" })
o = s:option(Value, _n("hysteria2_auth_password"), translate("Auth Password"))
o.password = true
o:depends({ [_n("protocol")] = "hysteria2"})
o = s:option(ListValue, _n("hysteria2_obfs_type"), translate("Obfs Type"))
o:value("", translate("Disable"))
o:value("salamander")
o:value("gecko")
o:depends({ [_n("protocol")] = "hysteria2" })
o = s:option(Value, _n("hysteria2_obfs_password"), translate("Obfs Password"))
o:depends({ [_n("hysteria2_obfs_type")] = "salamander" })
o:depends({ [_n("hysteria2_obfs_type")] = "gecko" })
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 })
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 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 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", [_n("hysteria2_realms")] = false })
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:depends({ [_n("outbound_node")] = "_iface" })
local netdev_list = api.get_network_devices()
for _, d in ipairs(netdev_list) do
o:value(d.name, d.label)
end
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,51 @@
local m, s = ...
if not api.is_finded("microsocks") then
return
end
local type_name = "Socks"
local option_prefix = "socks_"
local function _n(name)
return option_prefix .. name
end
-- [[ microsocks ]]
s.fields["type"]:value(type_name, "Socks")
if not s.fields["type"].default then
s.fields["type"].default = type_name
end
if s.val["type"] and s.val["type"] ~= type_name then
return
end
o = s:option(Value, _n("port"), translate("Listen Port"))
o.datatype = "port"
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 = 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 = s:option(Flag, _n("log"), translate("Log"))
o.default = "1"
api.luci_types(arg[1], m, s, type_name, option_prefix)
@@ -0,0 +1,77 @@
local m, s = ...
if not api.is_finded("ssserver") then
return
end
local type_name = "SS-Rust"
-- [[ Shadowsocks Rust ]]
s.fields["type"]:value(type_name, translate("Shadowsocks Rust"))
if s.val["type"] and 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 = {
"plain", "none",
"aes-128-gcm", "aes-256-gcm", "chacha20-ietf-poly1305",
"2022-blake3-aes-128-gcm", "2022-blake3-aes-256-gcm", "2022-blake3-chacha20-poly1305"
}
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,108 @@
local m, s = ...
if not api.is_finded("ssr-server") then
return
end
local type_name = "SSR"
-- [[ ShadowsocksR ]]
s.fields["type"]:value(type_name, translate("ShadowsocksR"))
if s.val["type"] and 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(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,59 @@
api = require "luci.passwall.api"
appname = api.appname
fs = api.fs
m = Map("passwall_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
local header = Template(appname .. "/server/config_header")
header.api = api
header.config = m.config
header.section = arg[1]
m:append(header)
m:append(Template(appname .. "/cbi/nodes_listvalue_com"))
s = m:section(NamedSection, arg[1], "user", "")
s.addremove = false
s.dynamic = false
local types_dir = "/usr/lib/lua/luci/model/cbi/" .. appname .. "/server/type/"
s.val = m:get(arg[1]) or {}
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, function(a, b)
if a == "socks.lua" then return true end
if b == "socks.lua" then return false end
return a < b
end)
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 .. "/server/config_footer")
footer.api = api
footer.config = m.config
footer.section = arg[1]
m:append(footer)
return m
File diff suppressed because it is too large. Load diff
+118
View File
@@ -0,0 +1,118 @@
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.order = {
"geoview",
"chinadns-ng",
"xray",
"sing-box",
"hysteria"
}
_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",
mipsel = "mipsle"
}
}
_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["chinadns-ng"] = {
name = "ChinaDNS-NG",
repo = "zfl9/chinadns-ng",
get_url = gh_release_url,
cmd_version = "-V | awk '{print $2}'",
zipped = false,
default_path = "/usr/bin/chinadns-ng",
match_fmt_str = "%s",
file_tree = {
x86_64 = "wolfssl@x86_64.*x86_64@",
x86 = "wolfssl@i386.*i686",
mips = "wolfssl@mips%-.*mips32%+soft_float@",
mips64 = "wolfssl@mips64%-.*mips64%+soft_float@",
mipsel = "wolfssl@mipsel.*mips32%+soft_float@",
mips64el = "wolfssl@mips64el%-.*mips64%+soft_float@",
aarch64 = "wolfssl_noasm@aarch64.*v8a",
rockchip = "wolfssl@aarch64.*v8a",
armv5 = "wolfssl@arm.*v5te",
armv6 = "wolfssl@arm.*v6t2",
armv7 = "wolfssl@arm.*eabihf.*v7a",
armv8 = "wolfssl_noasm@aarch64.*v8a",
riscv64 = "wolfssl@riscv64.*"
}
}
_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,256 @@
#!/usr/bin/lua
local action = arg[1]
local api = require "luci.passwall.api"
local sys = api.sys
local uci = api.uci
local jsonc = api.jsonc
local CONFIG = "passwall_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.passwall."
local ipt_bin = sys.exec("echo -n $(/usr/share/passwall/iptables.sh get_ipt_bin)")
local ip6t_bin = sys.exec("echo -n $(/usr/share/passwall/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 "PSW-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 "PSW-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 "PSW-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 PSW-SERVER")
ipt("-I INPUT -j PSW-SERVER")
ip6t("-N PSW-SERVER")
ip6t("-I INPUT -j PSW-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 PSW-SERVER\n')
nft_file:write('flush chain inet fw4 PSW-SERVER\n')
nft_file:write('insert rule inet fw4 input position 0 jump PSW-SERVER comment "PSW-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 == "Socks" then
local auth = ""
if user.auth and user.auth == "1" then
local username = user.username or ""
local password = user.password or ""
if username ~= "" and password ~= "" then
username = "-u " .. username
password = "-P " .. password
auth = username .. " " .. password
end
end
bin = ln_run("/usr/bin/microsocks", "microsocks_" .. id, string.format("-i :: -p %s %s", port, auth), log_path)
elseif 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", remarks, 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 PSW-SERVER -p tcp --dport %s -m comment --comment "%s" -j ACCEPT', port, remarks))
ip6t(string.format('-A PSW-SERVER -p tcp --dport %s -m comment --comment "%s" -j ACCEPT', port, remarks))
if udp_forward == 1 then
ipt(string.format('-A PSW-SERVER -p udp --dport %s -m comment --comment "%s" -j ACCEPT', port, remarks))
ip6t(string.format('-A PSW-SERVER -p udp --dport %s -m comment --comment "%s" -j ACCEPT', port, remarks))
end
else
nft_file:write(string.format('add rule inet fw4 PSW-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 PSW-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 PSW-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 PSW-SERVER 2>/dev/null")
ipt("-F PSW-SERVER 2>/dev/null")
ipt("-X PSW-SERVER 2>/dev/null")
ip6t("-D INPUT -j PSW-SERVER 2>/dev/null")
ip6t("-F PSW-SERVER 2>/dev/null")
ip6t("-X PSW-SERVER 2>/dev/null")
else
local nft_cmd = "handles=$(nft -a list chain inet fw4 input | grep -E \"PSW-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 PSW-SERVER 2>/dev/null")
cmd("nft delete chain inet fw4 PSW-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,173 @@
module("luci.passwall.util_hysteria2", package.seeall)
local api = require "luci.passwall.api"
local uci = api.uci
local jsonc = api.jsonc
function gen_config_server(node)
local config = {
listen = (function()
if node.hysteria2_realms and node.hysteria2_realm_url then
local url = node.hysteria2_realm_url:gsub("/+$", "")
if node.port then
url = url .. (url:find("?") and "&lport=" or "?lport=") .. node.port
end
return url
end
return ":" .. (node.port or "0")
end)(),
tls = {
cert = node.tls_certificateFile,
key = node.tls_keyFile,
},
obfs = (node.hysteria2_obfs_type and node.hysteria2_obfs_password) and {
type = node.hysteria2_obfs_type,
[node.hysteria2_obfs_type] = {
password = node.hysteria2_obfs_password
}
} 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,
realm = (node.hysteria2_realms and node.hysteria2_realm_stun) and {
stunServers = node.hysteria2_realm_stun
} or nil
}
return config
end
function gen_config(var)
local node_id = var["node"]
if not node_id then
print("node 不能为空")
return
end
local node = uci:get_all("passwall", node_id)
local local_tcp_redir_port = var["local_tcp_redir_port"]
local local_udp_redir_port = var["local_udp_redir_port"]
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 tcp_proxy_way = var["tcp_proxy_way"]
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 or "") .. "," .. (node.hysteria2_hop or "")):gsub("^[%s,]+", ""):gsub("[%s,]+$", ""):gsub(":", "-")
local config = {
server = (function()
if node.hysteria2_realms and node.hysteria2_realm_url then
return node.hysteria2_realm_url:gsub("/+$", "")
end
return server
end)(),
realm = (node.hysteria2_realms and node.hysteria2_realm_stun) and {
stunServers = node.hysteria2_realm_stun
} or nil,
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_type and node.hysteria2_obfs_password) and {
type = node.hysteria2_obfs_type,
[node.hysteria2_obfs_type] = {
password = node.hysteria2_obfs_password
}
} 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,
tcpRedirect = ("redirect" == tcp_proxy_way and local_tcp_redir_port) and {
listen = "0.0.0.0:" .. local_tcp_redir_port
} or nil,
tcpTProxy = ("tproxy" == tcp_proxy_way and local_tcp_redir_port) and {
listen = "0.0.0.0:" .. local_tcp_redir_port
} or nil,
udpTProxy = (local_udp_redir_port) and {
listen = "0.0.0.0:" .. local_udp_redir_port
} 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.passwall.util_naiveproxy", package.seeall)
local api = require "luci.passwall.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 不能为空")
return
end
local node = uci:get_all("passwall", 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,161 @@
module("luci.passwall.util_shadowsocks", package.seeall)
local api = require "luci.passwall.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 不能为空")
return
end
local node = uci:get_all("passwall", 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"]
local local_tcp_redir_port = var["local_tcp_redir_port"]
local local_tcp_redir_address = var["local_tcp_redir_address"] or "0.0.0.0"
local local_udp_redir_port = var["local_udp_redir_port"]
local local_udp_redir_address = var["local_udp_redir_address"] or "0.0.0.0"
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,
tcp_tproxy = var["tcp_tproxy"] and true or nil
}
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
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
if local_tcp_redir_address and local_tcp_redir_port then
table.insert(config.locals, {
protocol = "redir",
mode = "tcp_only",
tcp_redir = var["tcp_tproxy"] and "tproxy" or nil,
local_address = local_tcp_redir_address,
local_port = tonumber(local_tcp_redir_port)
})
end
if local_udp_redir_address and local_udp_redir_port then
table.insert(config.locals, {
protocol = "redir",
mode = "udp_only",
local_address = local_udp_redir_address,
local_port = tonumber(local_udp_redir_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
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,55 @@
<%
local api = require "luci.passwall.api"
-%>
<script type="text/javascript">
//<![CDATA[
(function () {
const startTime = Date.now();
const TIMEOUT = 3000;
const waitForDnsSelect = () => {
const dns_select = document.querySelector("select[id*='dns_shunt']");
if (dns_select) {
initDnsSelect(dns_select);
return;
}
if (Date.now() - startTime >= TIMEOUT) {
return;
}
requestAnimationFrame(waitForDnsSelect);
};
waitForDnsSelect();
const initDnsSelect = (dns_select) => {
if (dns_select.value === "chinadns-ng") {
addLogLink(dns_select);
}
if (dns_select._dnsLogBinded) return;
dns_select._dnsLogBinded = true;
dns_select.addEventListener("change", () => {
const existingLogLink = dns_select.parentElement.querySelector("a.dns-log-link");
if (existingLogLink) {
existingLogLink.remove();
}
if (dns_select.value === "chinadns-ng") {
addLogLink(dns_select);
}
});
};
const addLogLink = (select) => {
if (select.parentElement.querySelector("a.dns-log-link")) {
return;
}
const logLink = document.createElement("a");
logLink.innerHTML = "<%:Log%>";
logLink.href = "#";
logLink.className = "dns-log-link";
logLink.style.marginLeft = "10px";
logLink.setAttribute("onclick", "window.open('" + '<%=api.url("get_chinadns_log") .. "?flag=" .. section%>' + "', '_blank')");
select.insertAdjacentElement("afterend", logLink);
};
})();
//]]>
</script>
@@ -0,0 +1,213 @@
<%
local api = require "luci.passwall.api"
local com = require "luci.passwall.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">Passwall <%: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="passwall-check_btn"
onclick="onBtnClick(this,'passwall');" value="<%:Check update%>" />
<span id="passwall-check_btn-detail"></span>
<!--/div-->
</div>
</div>
<%for _, k in ipairs(com.order) do
local v = com[k]
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/passwall/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 => ({
"&":"&amp;", "<":"&lt;", ">":"&gt;", '"':"&quot;", "'":"&#39;"
}[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 => ({
"&":"&amp;", "<":"&lt;", ">":"&gt;", '"':"&quot;", "'":"&#39;"
}[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 => ({
"&":"&amp;", "<":"&lt;", ">":"&gt;", '"':"&quot;", "'":"&#39;"
}[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,24 @@
<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.passwall.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("备份失败!");
}
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; // 分片大小 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.passwall.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,293 @@
<%
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[
const shunt_list = JSON.parse('<%=self.shunt_list%>');
function to_edit_node(btn) {
if (!btn) return;
const idReg = /^cbid\..*\..*node$/;
let hidden_select = null;
const container = btn.closest('#cbi-<%=appname%>-global') || btn.closest('#cbi-<%=appname%>-socks');
if (!container) return null;
const selects = container.querySelectorAll('select[id^="cbid."][id*="."][id$="node"]');
for (const sel of selects) {
if ( idReg.test(sel.id) && getComputedStyle(sel).display === "none" && (sel.compareDocumentPosition(btn) & Node.DOCUMENT_POSITION_FOLLOWING)) {
hidden_select = sel;
}
}
if (!hidden_select) return;
let node_select_value = 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) {
return;
}
let 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);
}
location.href = to_url;
}
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
async function go() {
const _status = document.getElementsByClassName('_status');
for (let i = 0; i < _status.length; i++) {
const id = _status[i].getAttribute("socks_id");
XHR.get('<%=api.url("socks_status")%>', {
index: i,
id: id
},
function(x, result) {
const index = result.index;
let div = '';
const div1 = '<font style="font-weight:bold;" color="green">✓</font>&nbsp';
const div2 = '<font style="font-weight:bold;" color="red">X</font>&nbsp';
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;
}
);
}
let global_id = null;
const global = document.getElementById("cbi-<%=appname%>-global");
if (global) {
// 分流规则节点提前加入 “编辑” 占位符,以保证节点列表左对齐
document.querySelectorAll('.td[data-widget="nodes_listvalue"][data-name="_node"] > div').forEach(container => {
if (!container.querySelector('.shunt-node-placeholder')) {
const placeholder = document.createElement('div');
placeholder.className = 'shunt-node-placeholder';
placeholder.style.cssText = 'display:inline-flex; width:40px; flex-shrink:0;';
container.appendChild(placeholder);
}
});
await delay(100);
const node = global.getElementsByClassName("cbi-section-node")[0];
const node_id = node.getAttribute("id");
global_id = node_id;
const all_node = node.querySelectorAll("[id]");
const reg1 = /^cbid\..*node\.main$/;
//const reg1 = /^cbid\..*\.(tcp_node|udp_node)\.main$/;
for (let i = 0; i < all_node.length; i++) {
const el = all_node[i];
if (!reg1.test(el.id)) continue;
const node_select = el;
if (!node_select) continue;
const cbid = el.id.replace(/\.main$/, "");
const hidden_select = document.getElementById(cbid);
const node_select_value = hidden_select?.options[0]?.value;
if (!node_select_value || node_select_value.indexOf("tcp") === 0 || node_select_value.indexOf("_default") === 0 || node_select_value.indexOf("_direct") === 0 || node_select_value.indexOf("_blackhole") === 0) {
continue;
}
let html = '<a href="#" onclick="return to_edit_node(this);"><%:Edit%></a>';
const m = cbid.match(/\.(tcp|udp)_node$/);
if (m && (m[1] === "tcp" || m[1] === "udp")) {
html += '<a href="#" onclick="window.open(\'' + '<%=api.url("get_redir_log")%>?name=default&proto=' + m[1] + '\', \'_blank\')"><%:Log%></a>';
}
html = '<div class="node-actions" style="display:inline-flex; align-items:center; gap:4px; flex-wrap:wrap; margin-left:4px;">' + html + '</div>'
const placeholder = node_select.parentElement.querySelector('.shunt-node-placeholder');
if (placeholder) {
placeholder.innerHTML = html;
} else {
node_select.insertAdjacentHTML("beforeend", html);
}
}
}
const socks = document.getElementById("cbi-<%=appname%>-socks");
if (socks) {
const socks_enabled_dom = document.getElementById(global_id + "-socks_enabled");
const descr = socks.getElementsByClassName("cbi-section-descr")[0];
descr.replaceWith(socks_enabled_dom);
const rows = socks.getElementsByClassName("cbi-section-table-row");
for (let i = 0; i < rows.length; i++) {
try {
const row = rows[i];
const id = row.id;
if (!id) continue;
let dom_id = id + "-node";
const cbid = dom_id.replace("cbi-", "cbid-").replace(new RegExp("-", 'g'), ".");
dom_id = cbid + ".main";
const node_select = document.getElementById(dom_id);
if (!node_select) continue;
let html = '<a href="#" onclick="return to_edit_node(this);"><%: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) {
}
}
}
}
(function () {
let lastCount = 0;
let stableTimes = 0;
const STABLE_THRESHOLD = 5;
function waitStable() {
const list = document.querySelectorAll('[id^="cbid.<%=appname%>."][id$="node.main"]');
const count = list.length;
if (count === lastCount && count > 0) {
stableTimes++;
} else {
stableTimes = 0;
lastCount = count;
}
if (stableTimes >= STABLE_THRESHOLD) {
go();
return;
}
requestAnimationFrame(waitStable);
}
waitStable();
})();
(function () {
const startTime = Date.now();
const TIMEOUT = 3000;
const waitForDnsSelect = () => {
const dns_select = document.querySelector("select[id*='dns_shunt']");
if (dns_select) {
initDnsSelect(dns_select);
return;
}
if (Date.now() - startTime >= TIMEOUT) {
return;
}
requestAnimationFrame(waitForDnsSelect);
};
waitForDnsSelect();
const initDnsSelect = (dns_select) => {
if (dns_select.value === "chinadns-ng") {
addLogLink(dns_select);
}
if (dns_select._dnsLogBinded) return;
dns_select._dnsLogBinded = true;
dns_select.addEventListener("change", () => {
const existingLogLink = dns_select.parentElement.querySelector("a.dns-log-link");
if (existingLogLink) {
existingLogLink.remove();
}
if (dns_select.value === "chinadns-ng") {
addLogLink(dns_select);
}
});
};
const addLogLink = (select) => {
if (select.parentElement.querySelector("a.dns-log-link")) {
return;
}
const logLink = document.createElement("a");
logLink.innerHTML = "<%:Log%>";
logLink.href = "#";
logLink.className = "dns-log-link";
logLink.style.marginLeft = "10px";
logLink.setAttribute("onclick", "window.open('" + '<%=api.url("get_chinadns_log")%>' + "?flag=default', '_blank')");
select.insertAdjacentElement("afterend", logLink);
};
})();
document.addEventListener("DOMContentLoaded", () => setTimeout(() => {
const hiddenSelect = document.getElementById("cbid.<%=appname%>.<%=self.global_cfgid%>.tcp_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({
tcp_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);
}
}
}, 100));
//]]>
</script>
@@ -0,0 +1,110 @@
<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">
<%:Switch Mode%>
</label>
<div class="cbi-value-field">
<div>
<input class="btn cbi-button cbi-button-apply" type="button" onclick="switch_gfw_mode()" value="<%:GFW List%>" />
<input class="btn cbi-button cbi-button-apply" type="button" onclick="switch_chnroute_mode()" value="<%:Not China List%>" />
<input class="btn cbi-button cbi-button-apply" type="button" onclick="switch_returnhome_mode()" value="<%:China List%>" />
<input class="btn cbi-button cbi-button-apply" type="button" onclick="switch_global_mode()" value="<%:Global Proxy%>" />
</div>
</div>
</div>
<script>
var opt = {
base: 'cbid.passwall.<%=self.cfgid or section%>',
client: true,
get: function (opt) {
var obj;
var id = this.base + '.' + 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;
}
},
set: function (opt, val) {
var obj;
obj = this.get(opt);
if (obj) {
var event = document.createEvent("HTMLEvents");
event.initEvent("change", true, true);
if (obj.type === 'checkbox') {
obj.checked = val;
} else {
obj.value = val;
if (obj.combobox) {
obj.combobox.value = val;
}
var list = obj.getElementsByTagName("li");
if (list.length > 0) {
for (var i = 0; i < list.length; i++) {
var li = list[i];
var data = li.getAttribute("data-value");
li.removeAttribute("selected");
li.removeAttribute("display");
if (data && data == val) {
li.setAttribute("selected", true);
li.setAttribute("display", "0");
}
}
var input = document.getElementsByName(obj.id)[0];
if (input) {
input.value = val;
} else {
var input = document.createElement("input");
input.setAttribute("type", "hidden");
input.setAttribute("name", obj.id);
input.setAttribute("value", val);
obj.appendChild(input);
}
}
}
try {
obj.dispatchEvent(event);
} catch (err) {
}
}
}
}
function switch_gfw_mode() {
opt.set("use_gfw_list", true);
opt.set("chn_list", "0");
opt.set("tcp_proxy_mode", "disable");
opt.set("udp_proxy_mode", "disable");
}
function switch_chnroute_mode() {
opt.set("use_gfw_list", true);
opt.set("chn_list", "direct");
opt.set("tcp_proxy_mode", "proxy");
opt.set("udp_proxy_mode", "proxy");
}
function switch_returnhome_mode() {
opt.set("use_gfw_list", false);
opt.set("chn_list", "proxy");
opt.set("tcp_proxy_mode", "disable");
opt.set("udp_proxy_mode", "disable");
}
function switch_global_mode() {
opt.set("use_gfw_list", false);
opt.set("chn_list", "0");
opt.set("tcp_proxy_mode", "proxy");
opt.set("udp_proxy_mode", "proxy");
}
</script>
@@ -0,0 +1,281 @@
<%
local api = require "luci.passwall.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;-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:-apple-system,BlinkMacSystemFont,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;
border-radius: 12.375px;
}
.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) {
.img-con {
margin: 1.5rem 0.5rem;
}
.block img {
width: 36px;
}
}
</style>
<fieldset id="_passwall_status_fieldset" class="cbi-section">
<legend>
<%:Running Status%>
</legend>
<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_tcp_node">TCP<br /><span class="red"><%:NOT RUNNING%></span></h4>
</div>
</div>
</div>
<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/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4NCjwhLS0gR2VuZXJhdG9yOiBBZG9iZSBJbGx1c3RyYXRvciAyMy4wLjEsIFNWRyBFeHBvcnQgUGx1Zy1JbiAuIFNWRyBWZXJzaW9uOiA2LjAwIEJ1aWxkIDApICAtLT4NCjxzdmcgdmVyc2lvbj0iMS4xIiBpZD0i5Zu+5bGCXzEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4Ig0KCSB2aWV3Qm94PSIwIDAgMTAwIDEwMCIgc3R5bGU9ImVuYWJsZS1iYWNrZ3JvdW5kOm5ldyAwIDAgMTAwIDEwMDsiIHhtbDpzcGFjZT0icHJlc2VydmUiPg0KPHN0eWxlIHR5cGU9InRleHQvY3NzIj4NCgkuc3Qwe2ZpbGw6I0ZCQjE0MDt9DQoJLnN0MXtmaWxsOiNGRkZGRkY7fQ0KPC9zdHlsZT4NCjxjaXJjbGUgY2xhc3M9InN0MCIgY3g9IjUwIiBjeT0iNTAiIHI9IjUwIi8+DQo8cGF0aCBjbGFzcz0ic3QxIiBkPSJNODYuOCw0Ny42QzgzLjYsMzMsNzQuOCwyMi41LDY2LjUsMjNoLTAuM2MtMy4zLDAtNi41LDEuMi04LjksMy4zYy0xLjgsMi00LjQsMy4yLTcuMSwzLjINCgljLTIuNiwwLTUuMS0xLjEtNi45LTNjLTIuNS0yLjMtNS44LTMuNS05LjEtMy41aC0wLjRjLTguMi0wLjUtMTcuMSwxMC0yMC4zLDI0LjZjLTMuNSwxNS41LDAuOCwyOS44LDkuNSwzMS45DQoJYzYuMywxLjYsMTMuMy0zLjksMTcuOS0xM2gxOC43YzQuNyw5LjEsMTEuNiwxNC42LDE3LjksMTNDODYsNzcuNCw5MC4yLDYzLjEsODYuOCw0Ny42TDg2LjgsNDcuNnogTTQyLjksNDguNUgzNnY3LjJoLTUuNnYtNy4yDQoJaC02Ljl2LTUuOGg2Ljl2LTcuMkgzNnY3LjJoNi45TDQyLjksNDguNXogTTYyLjcsNDkuMmMtMiwwLjEtMy43LTEuNC0zLjgtMy40Yy0wLjEtMiwxLjQtMy43LDMuNC0zLjhjMi0wLjEsMy43LDEuNCwzLjgsMy40DQoJYzAsMC4xLDAsMC4xLDAsMC4yQzY2LjIsNDcuNSw2NC43LDQ5LjEsNjIuNyw0OS4yQzYyLjcsNDkuMiw2Mi43LDQ5LjIsNjIuNyw0OS4yeiBNNzMuNyw0OS4yYy0yLDAuMS0zLjctMS40LTMuOC0zLjQNCgljLTAuMS0yLDEuNC0zLjcsMy40LTMuOGMyLTAuMSwzLjcsMS40LDMuOCwzLjRjMCwwLjEsMCwwLjEsMCwwLjJjMC4xLDEuOS0xLjQsMy41LTMuMywzLjZINzMuN3oiLz4NCjwvc3ZnPg0K" />
</div>
</div>
<div class="pure-u-2-3">
<h4 id="status_udp_node">UDP<br /><span class="red"><%:NOT RUNNING%></span></h4>
</div>
</div>
</div>
<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,iVBORw0KGgoAAAANSUhEUgAAAgAAAAIACAMAAADDpiTIAAAARVBMVEVHcEwAAQICBQcCBwowdKoYZqRNpOkfdLdRrPX////D2ux4q9QvnfQrm/QomfMlmPMilvMfj+ggjOIhidshgtAgfMUcZ6LUtOjpAAAACXRSTlMADR8zZaKn+vzLxsICAABO4klEQVR42uydy5brqBJEjezlpUGiOv//tfeWqfaGSuthEgnkIqSS5TPoQUcS+QRfOjo6Ojo6Ojo6Ojo6Ojo6Ojo6Ojo6Ojo6Ojo6Ojo6PgVu9tH/33wm3e7XNfO4PN66IXwE5/CuMEe/i2zAcXV7OCMcb/pKSeYtsgX1n+vKcCq5j3mH6XCHj4vjjTt8/r6gv+tBs8CJ8w2evy/on8UF48AUfocJXQwag4P2VAFYzWC43W73Gdxut8H9AKmIZaBHiS0CYmLHHhM/BNLHbVC2kFhBzxfa8/jJqncZvC+aApZAyHhxvXhQG06t/Vjvr7dZ4mWU1xhllDlDuF0RA6ytm0Bd9uGBIN45qN9EO/CPB9agzSCIwSV2Cj03qMO+zu9+1r2mHVp5A8D/gH/AEsBTC9IssevA0fTDPgtfU69496zzAO/lcSUQEWxBMAOkwAXgDboQ7A+yexZ+wC3lXgAsc70B8RIbQioFUVBwcb2ldGzkB/1DsvJf+/b0khc8LwNZSMzgfr9iA64nhrvCEfhRzhtY+nCv2NfXz+2FNe43QgIiIRiimLC7gl0dP9Kvlv4G8rlY95kIQQRCMET1ok7/bmFfvPav9yTc8y+5XxOASAbeRyoEwRmQFPQ2cmH6uWD/AcU83If71QXe5356Xp6YIIkKH1dvGRVP+d3lV8Sv03pyPL/Mv4Q7wwam9BUhwAYurptAYfqR/vt6zBcemyQAT5ChAOE1QNCB4SkDfYrEBje/+GUGPvzFCvCS/Yz1D/+8hef0fwST066gFwayQTbF4l+kP13yWIPXl0UAkIDnx/RtBB4b+CUD3QYytZ/2rlr8ayawrADJ/V4ZANXn8xvBCrx40TLQTSDX9ZPyw/42+ldiAAOQgEgBHrcPVuB9KgMX99CyTn/elMf3PUD/GqCch3b/3Ly9qQC8BNqn8PG4cAWRCbheGdgChFL1eQRsUQDeIugQIF8AMIIJDfi5qQ4824a9TfBO4I/432B/K/+pFWgFYPHzZTvgHvYnr25s4H57sN87xm8G/k65/gwTUA4/sQKFvEDwBf8hMBTBEzzQQ4E1pHnf/V32Nf/LTSBBC94lf+JSCgAwgRAK9EBgmXoSv/foB17fAAVIdV8yBADMKUAA8SA14p4VvAYjfor+kgoAJJ/8Kb40/Qk8JhD47+wv7NY1rf5X9AMUoIQAAKoAfPzCf/FgVCHuAQFgzCuHfuD5Wy0BEf9nhAAzAsC7sgAfm0Dfcj4jADOJn8EHiPhVCZCMJCAB6q/Xv44Hb+6BnhJG1MO+jX6iwHUFgPuMMsA0kwTwAFoFbj0SeJ0AMOkjmfCrMYDqBpsFQFUBw2NJBcL0WE8Kn8wz5GmjX9/6SjkXowDg9xeDQB0NDq6fPvNAHPtDf0ELmBcAL0UEAOGPngvwcSjwp/MBl5Z9b2XoX1eAtPiX0Qt8JQC8hudGE/jboQDRn1L/3RWAb2aQAeAGwFw0SIcAR/gXU39i/2L0b4oBROUAdgXgHWwQgb+qAWrKX2zQreCFJCCm3wjo53MDPCLw9+JBB/vfgP5yFrA0Di56ItQuAFkm8IdLgyR/V6X+5hCQN68gqg9kBORTBtgGEUm3kuAb/0juX3D5s+65lzYFKY9wnAKArwkR+Evz4+T+F4I/OygFowIacG8MA7AIbQIb8fX1UAEywnB/PvnlvD/w2yRAMW5WAD6who34+j+IBL7xFzwAyd9QevnrFFBBeAm3kX/VBgyP7QbwFYLBkA4QHn/4fg9Kf1IMfkMVANqBmAWAUOBdBQjwXqgNf/iBY+R+A/IvYxn2dR1wFsJtFAAUgJLwmwYwTV6eDaLPLQ7T9Si//FEARGDm0pyLXQHeX/3wH0yAWJBU4OMbf0XpV/d8CEAuaHUAKfXvJwFg+txYEJeG/C/TPxqW/3oSQDGwZAyIDOQoAOnAwOz4x9b+L6n8j6VTAPifQbk6MF6ftwwFULEgQ9KfJ/7IP9zb4WVbHVBLv10BknmwDAUAHjdAEPBhJhCi/3LiTwCwXQEyK8FwLtEzNoJcBQCUBJCBzyr+XWP5Ly4Aqwqguc+aB+QRfcuNAbUIXOkOuY9Z/7r4M7Lmi9cBZIFgMceAkyAAeijYZAATgQBHjH0C+WR/Uh5eVwKX1j/c53qAOObjNisACeEdL/A56x/3v77w7VngHLdiTQJlCgqg+TdkgSoboCwY7rPv9if7N8CwH0D1AiV7HDA8MAHVETAZAIHAgAR8QvRP+DcKWK0G2LcFAyg3CcAz+xMWPbZg559A4NzJANU/xv4NGZ9lHJhLB4GSoQAEAqg/XsBqAAQCt5PzT/UP/ouTD/FbcgAdBr7JPypAJ9DzalUA4EXQgJAM9PDf3gfADEzDQEEAJF793HYDQAM4Vuas63+J/3EPBYB+DZ0AiiEG4I5fixkAFkAucELtT9O/cZX2sVAOuJYHvI3Y8Ut4aPqL5ADAp3XhswH+A/2a6IoKkDUNROgvUJ8/EbyOiXTw5zpX/m9P/+0nA3HZasEEf+iADgAKegDcwHBGCaD7e92ffq82BS7kAMLXrPn/YAS/m0F8LWYAWMD1RAUB8n+d/m0IA8qfDMRlPR4wTQFC5sc/FSoDaoR0MOQC50gGYR/+VzCaBYDPTY1Ayd4WroKAb3hzI2C9IBAS6rOQ/5v/ce8IYH0a2H46dOL9WfiwX9wAqAvfznWm1Or6H0tHALEHWEsBJGNLwKSygHRjaPksUJeFTzEqRvIP/3C+QzHI632BC7NAuTKA+vOR8r6DAAAfNwZO4gTm9X/cLQXYMAsUYBAAcgEV/meMA+ZYQONW4HT776gawEoEgAuwhgBK9/cKAcAUxwHNN4AM7T+LAKznADyyZ0Ek8B39WuB+SYDWAEzANX3eP/yD1QrAaDwSYnsOINkRgKcXoMOA3TwAGtB0X8D9PKj/gRYEgJWfdUI8gyAhBEjDv4wyYKYGsIG4df9vEP/y+4Hg3nwuxMvwb58kUGvAlVzgVP2/0tGA3g+0lAIAqM+tAjINqhVgnxAA0BcgDmitAOjgX6F4OuhlYw6gxD9rGDCQPZcC7icAwEuzw8IuLgCi/7unADxWIgC4NyqAh3Wo3zcHAHSHiQMaUgB2/0oGrBsCVwXAPA8+U//LGAe0WMCdIbEm5//hf6vij8YIYGsKgB1klQFRAEEGDOOAWZiCBdAaak8Bbuj/rl6ACGBLDQDuc0cBJFJ61n/mOKDJAp7N4SZ7QLdj9f8tATBNAuAIZBJItw2EGwpCnDDeSAXQHTIAAPD+qwrAnQMCQGJ9yD9MAYBvrCToZgoA6P+hNaAFZJ8NCNnyCAA81B+XA4CkIOQacf/wv46xvAKsRwA2BaASkD68YRzQZAFDG5vGyACWE8BxFwV4owuACWQqgMR/iL+hE2iAT1OB+g4A/uF7N/lHAbZGAKZKcFoEZPHj9WH/IAPAAi6uFQXISgDGQtuB4H95/ZtOh/bsCCAmMJ8LYE8G6zoBl+4AMTJefkMgms9f9jAgowDUAApsCbJ1BuunAi7pAOjO33jgdrB1SKYDwAzSE2EOrgKAf34c1ZBg7eOfBYwHFoLWawBmFyDMAgmkmw6Hs2sAR4k1cgCgHASc/tYIwHo89NwsqGFDgB0itU8RcqoDYFj5lpMB/cogkDkFELpBKvw7WgFAUhOuPwIkugK4vwIsHwokljYg/McnwyH8lj2B5cKAAQ2omgHcRwPd9hhgRQDw/tkKkOwKMh4MU1ID7jV/Y0Q7AMvCL38wqFj7gInSTzoI4FHJALxU+flpwj8qAIr7cfdWQLhRgEUBEIMA4PsF0rmrCAAWcK3nBMgAbZTafx7K8ltQ6/yTBvAVETDkAMVzQVdj/VMBAOOuzSB1JMSsAkh8W4YBhYlANQtcQwGAYAG1KwA851D+N4KxBi/7VAFZ4eEf8AG81DEAnECd7UKOTQByMHQBYIsAmE6F8ZOo7cBIQRX+yQUrSICrFABoBfAoQGHAfiT7yQsxYEUDwAmgAMdsAyUDRP+rKYCWgN++X4xVYOFoQDSgVgQA/v37SseEXY0SoMHvGzNAPfehIZYqMD0AXQKqWASA/39fPjpL0h3Ifi0HkGSAWgF0FyD/ZMh4Rwi2oHrBdQ0AJ3BQT8ilx4BV0P9w8wnZCrEPMCgAnPMMj9oe4BvTwQVBFGAY6wiAUv9XAmDyAaoEJHylEdSCB8AJHCgB9RwA4o8NQHgKHRrYBUC5/9oeIIBMABPYfwp4uFYQAMRfLfoFARDjsWDhJiggNWzAA4Qw4EomcIACIABVFYDcjjeAIWRHAAwBcKt50BYMYCIOfFxHJADDbX7oczxOAVj0wqt6GopAEm8GaUMB4B8JOHK/ICWA6goAyWL9bSCgBv51A5BHVQEAXsbhPx8Q7h0FIPCPA6hlAakCaAGwh4A80h0BkN+IAYRiAKnAcVNAB9cBU/cPw1oBxBADEgIiBnPnw9b3ADiBfUeEmQJYcADjcfPAXLgBg+8H+rdBiAC4fRshIJiEEdFDfgpE7LALQADfVARgEgA/kQwq8qG/GQO4uZ01gPWvBGA8MgKYV4CCEQAVgPCifie4Ff5xAjvXA12VMUCwtBFICwAQ6yDAbBLQmAFMUT1wRweAAFQrA6prVgDEJgAEgVBOT6C2AhwtAY4UoGYKCOdACwIwRwCa+5rbAcA/jTQVdDvwTw2wBla3AdoFgOXODwPIyzmQdjwA2LUvDP/O7gDsIeCSAoh9FBiWZSUBrC8AwMt9GHACe20EqCUAagaAC1ADEHsfmFXPTVO4KQNAAnbzAZXGQLine truncated
</div>
</div>
<div class="pure-u-2-3">
<h4 id="status_dns">DNS<br /><span class="red"><%:NOT RUNNING%></span></h4>
</div>
</div>
</div>
<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,iVBORw0KGgoAAAANSUhEUgAAAQAAAAEECAMAAADwPRpCAAAAAXNSR0IArs4c6QAAAIpQTFRFR3BMJUVjJURiJUNiJURiJURiJURiI1qBJkZlJUVkJURjJFyEJ0pqJ0lpE5nQBaXZD5/WDaDYFZnUDZ/VDaDYEJ/XDqDXKXOtLna1KXWwMHm8CqLXFprVLXWzE5rSJW2kKEpqJ2+oKEprKEpqJm6lAKjaKEtrLHu7JGyfM3e7JW6lM3i8FpvWAKjaFM8leAAAACp0Uk5TAGUrW1FGPA0WITQFdYQtu9xDHK11XImCQZq7mPFVzOjFz/ils+rgaWvlYxD5IwAAGzVJREFUeNrtnQl72roShrUvlg0Bwp5QAk3CPfee///3LgaTqZClsQ1uac/52qZ5EgPWq5nRaDX5yRptnp7e39//HpErTf4+6v3vd03+bE3/PqkeQCn3TwUwvAD41wL+tQBPzv2mBR2WmkymxNcIAxCzjPFvWtPThhagNwgA/YcAGA2HT8Ph5umVeDJ8sl6vJ8cvfyoAUKbKAgtFCIhRe8ZAyJ8PQFhSSglDKsH33Pz5ABz9qnVWub+U7kKC/fkArAS/V+f/MvilIH9KfjB9ej8l/QAgrGQnhT67P5hHUM71ZH3UcP27ATD0ZLNSEVDo5owKYfwAEbyPOWFT5DeTPNu1E4yAgkCvaZF5v2aKeHKcVaZhyINqXPnoMykVOLOhNl5CwzOIgAEfz4KYIJ5mw7NGDwNg5hu6/ULBtVcie936Z9zGPIQoCc7BfADVxw4e0wIyuHGoO4hyfutvKfNiJIhxuNpwVwfg11uABgvwIqBfjWE7p4V0V9/CBRAAQUo9ogUAgDWDQkvfXEUGpcjA/QlIcROaiAbfuI6DJps8HgDJubLOq8WwJNyA+4MgLYSGELhd+ZJm5SetHw7ArKwXQUVmIAIGtqw5uL8n6B0xFZg8xEFnFeeS6UdyATccDjeb4XBKSpV3WORME1+L5fNJs/L/UeXzvpwSejw9/rq6dDl314xkceJ70uzprAcAcMnvobozycXZGyrp4f9+0Ms8I3VidPK/H3XJosHu/T40deRB5AOQjIA3nDXeeCX7JPUym1oAYPfOyxIoeRgZUdPQwX2T8ZNXslHMmHwLeCZA0ouDEE8eQMG9cOtbLhXKpgGArgBUr3ZeHATqDwTAUW/AC1TVYd7JApal/QTtifPM7idrVIlci3o5YKCOFkBqpBQEHuT+7id8+oo6iIChXNcYEAriICQMoOr2hj8fANcQAfu1AIiDWfbrAJBrCQMRMJS7IwAwMsUeCIC0EAH7tQAIM9I+FAC4tX4tAOJg2J2QPw2ANMFQLxgnbgEHUi+9wQFAl5Kbq+Jz9tMAMA6dVghITJCI/FT4aV5/oc09C3iJAmCiCrx+8YkDAP3lAfPpUWW4r3IyNy01L3+8fJ6OSb2e15PJZDOcnPSsBTUkkCrYaLaezY49we/Px7cbRd7MPZ/faTMpNb0U/3x/x7so7683ORiwsecPHTWzWj0ej4vxSdoRkhUZ8eV+hCIVcYQ0cpSNI0bRDHqm1PWe9ILZ0cz5AGYkLpN75i5986eSgDJB4tJPnj+VxXdegOi73wcq2R8aA2A8VuNgEQErHAAUHxqk3nv+IH1AXQDsOvB5UimICYVrDECHeVKPChPw5i5AoWp8NzBUINd6cj4AQAUNUo9SWQQAbgGFDocRuCaEFSpqLTiAjQurqD+FHuYQAIhfq5zJ3JJAmWhsATp00p4U5p+GKbEMAeAxEJTtC43QQgBQLhSz2gvT9x7/GAT9Xm2ZFJTLzE4TAHCrtrnkvI5A4RpbgDZMSV7eDDPuvkOFz+EyF0qMzSSnQjHTLgZQVusBhNT6ALUtXcCV9yVKcyis9ruH7+/DGwGMLp/BZHGqdg3X+BawJFHlmoTJ3+lnLG8VBU0qBmjDCgHmUBXgdgC6qnbFHQGFAFbCoTEwTAYNFa5xFMyKF6/LpOvC1MUc7gRg/lXtTJJ0Myhzi8fAMPmDlyFRUAtqhn4zGG+otLkTgKmLZxnj2amjd9bLlLBc4jEwTP4Y4EhEwfO7D6YnLT/LLuOAxJeh3dEF4M3RLE9zakgoDq+E5M//CRYFwVDgChdJVvsCIGyDCKeKDLkC6jtqEyKMgtYPFSJDuysOBYDnAaWljbwAU6eCgGqDms4jFRmJChAFo1yVRDusbj1cn9R9/MMRK/AZaZ3X2epiBXr7z6oS0RyakqCKCVlVOr4AtCCGc9OsodD8evzUkZskGQChsTuvi1av/62T3+qHw0K1L/rMCoW0KyDqzSR2ET4fid+L5rQeQM5IQqqofdGc2ub9Ba695aqdhM9H4taovteWRZOkbD2ABn4XBirL7zUOGJmPRBLXQ21ZCKLaF72SFh0maWExwX3EZF0agLZIrz0DyE3EYhnM1N5JwqYHHLntH0DzHmOWwV3dSZan04Dc/HQAeCbE5P1X/3ONJIK3A3AYALzLbMT9dxhoqhNpgCtqf9oXADwTuv8eE60k5FhNW+TPXgDgmZCjVZXdcwfUZrM5z0eu15NxmAj2DeBgXA13Gt7oZH3Uqvw3mcw6IBgMz5oRTzN/BWeiKmB0Li9qE6H/YLZWnwjlRX4alTEumQn56xAG7XfZDGr3v5Dn9AIPJashw3PBcypOt4pYQBsAi9NAtBIc3r02E/LXJA8IssvmPgC0oVTysuBXI/Sft1sAAKjkgdhLa1x7AAkLcF8AXMIFRtd3UlDFDBS8TwAeiIKCveEAXBsLmFDKhZQqY9YY7XwL+MzA0plxkJL1CiA2zKZtpr5uZ+MDcNpYyzIlBeeUThoAGH25gPfa7x6AZeWESCLYDYCJAEAyoQqED2BOuRBftdgqBjwTzAXwbln/AJRMB8FR+yA42myeSs0QAFgi2B3AuAUAxtsBwHfZQDeyDQCT/yQAeAI2CQB4orpV7xe0DgHgiSBZzCt9f/v67tscC4Jv3+aVll/fzcfNxmKdD+AzMq6BKLjQiFUSQCZIWkUB326RSuBwgf0giIq0BcyFDSoWU7jUTCvO0i6gMLB7CnW036eNb8fgk3cEUa7TLmC51DWujStT8C3NMBeQiqS1lQS0TRFgO0ZAO8xlqQ0B+Dea0cx1WD+mqaugcWlOoelLg4LV5CNpfWQEZLYFicnuuPe64I3RT5Y53Kq+GPClUK1HgIwMvcZlFKmHUDtDQPO/Sn3769uX3r69VTp+rwloq/Db9GUKQzxBKdqNkBh+sf5QXGGeCAp92ZUAEvIABE6LRR8aFBLsmOp282GMKx1pfCyWCPpiXjRfIgDqg2ezCRnFYxdSlUnSRlkhTPR39AoIBnNLCAizgKD5BGGZkC2i9+zUvg0Ap3huSFRCIiNTvvi+owXk3gvxwUiapVhBQEPFqHKQDGFOwDjWWncAEJLDh6OlSM9xWyENwVVdl4wZLHdtEsG8BQAX8R08E7KpYKzppWYJIi15YgtcCBtPBN2edgSQfTSYtgUYDE/tnKLRqyDxg2QoKpczbIYKtJUdAVg0F+asmQOAPZuyggPN3k96WnCpkZ0Hob3hiSDrCMAhAAA+eCXevbNCrs/FhZGBy5EsB+snQ42cgBo0EWwBIHhlUkqGJol38NmqKm8AYNCi90izhomg2ZFuAMB28ExIyDZDPGsEAN59XJxHdy6q1oAZJBFsDWAbK9bn6qTl6m151vf5fP6ZagM9zZIAQNykAIQaN0gEXRsA+5gXzus+fpVoAztZAGGyM4B4OvO9DYBYlr1sASDsB+pJDEDOgsajDQCDDIi1doGi6AggWQxGQwCTo4aT4UBIHaYP3QGE1cjUWxoAVxbrDOAAUoaspTDrsrhHjesSIeaPDLUBAPCuHdkqURTb7W73sf2WBlBetNtui0IpTXhbAHgbaDmStxnPCCS7CYBWqih2lyIJZdEY8O1sJrzCtdsXuVJNAeBtoAtPcUsbgeGdACiVF/vtx7ncO+5RxAGA6EcO78MVQwCgbaDlAUzMCIRtAUB91dwWag7SufYAzA4s6UgCLKkFAE2h+qEbgBtBaD/4CriL7waF6AQgHBiHWPJXcwCZQqofMQJumluAQxLB9gA+7I1BENpAqP4WRqAHo8/DqNIYtQCHJIIdANSHbFcLYElA40Gl18NofK7+8O5wI/DXG4w7AaD71gDwgfEVYgHTpxdvhgiqv50R+BvVYdZSLpsDKIobAOxpCxd4k/ZrOZC/pkdIR7rI1AAwGS+obLEVYJ/fAKDI21iApAXPTLigYWlJR/nH/2jHZJ4Lpj0XQAHwWwAUGAA/BmgmTncYrOzsqKud+gVVtvVC8K28AQDdtwNQyiherHoBABPNCIDkqM5bGwBi2xJApR4sAIIgCiCdy7zOSy3ny0rfl99/FPGk6gHob30AwIMgthWAx0cEQS6xk9GlsiiQ2bYDMDekrZxhSlARAEivhN9ByEImNxClV8mwXYG5wLPfCoivEy9Ag1Lj8Zgcx8dLDUklB0dkxF3ALBaH5etJnxfN53q7J4NFqWwwYMc/1o75dmyM0aVcw0LPf9Bf82st+Acv/Qi8aLVcrd5Wq9VnDMC0rNCqUOHZiO+wwVpXZbcGiQHBWgx3VPkG2y025IfLpt/hOywjClYGRAGU8ikAgOr/TSY4HA+CA4h0rfbfkCCPa5wGEOkf0QwF4FMIAEDZQcFpHZFFEu1bue4WMI8E7EIT0Cx2tAtQ2ACA6BkDbDkrtT5qstnMIg7QLs/BNUgDOJB6Ke4/2vXlqdJkeFAk1BAHkHEdLAkAB+gNAMMBoE7gxofD4KIxkSoK4D0OIBM6XBcDtB/NAohNrJaQMsxxLhYwGZ60MenyE5dbzwEezgKIFL45pAgY+lzpq/Wn1nep8Gwj6jnAw1kAcZTFTVRJv/xhWwIEguvBCeDdHwoA2D00CSGBWFmBCotYDCwOAwfoD0CGAkCdgKroYgoofyjDWbz84ARckLine truncated
</div>
</div>
<div class="pure-u-2-3">
<h4 id="status_haproxy">HAPROXY<br /><span class="red">N/A</span></h4>
</div>
</div>
</div>
<div class="pure-u-1-4 check" onclick="check_connect('aliyun', 'https://www.aliyun.com')" title="<%:TLS handshake test, latency for reference only%>">
<div class="block pure-g">
<div class="pure-u-1-3">
<div class="img-con">
<img src="data:image/svg+xml;base64,PHN2ZyB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiBoZWlnaHQ9IjQwMCIgd2lkdGg9IjQwMCIgdmVyc2lvbj0iMS4xIiB2aWV3Qm94PSIwIDAgMTY1MyAxNjUzIiBjbGFzcz0iaWNvbiIgcHJlc2VydmVBc3BlY3RSYXRpbz0ieE1pZFlNaWQgbWVldCI+Cgk8cmVjdCB3aWR0aD0iMTY1MyIgaGVpZ2h0PSIxNjUzIiBmaWxsPSJ0cmFuc3BhcmVudCIvPgoJPGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMCwgMzE0LjUpIj4KCQk8cGF0aCBmaWxsPSIjRjc2RTA1IiBkPSJNMjk1LjYzMzQ4OCA3ODMuNjMyODE3YTc0Ljg5ODIxNiA3NC44OTgyMTYgMCAwIDEtNTkuNzIwNjA0LTcyLjU4ODU4di0zOTkuMjM3MTg4YTc3LjUzNzgwMSA3Ny41Mzc4MDEgMCAwIDEgNTkuNzIwNjA0LTcyLjU4ODU4bDM3MC42OTY2NzktODAuMzQyMzYgMzguOTMzODc0LTE1OC44NzAwMDVIMjc0Ljg0Njc1OUEyNzIuODY3MDcgMjcyLjg2NzA3IDAgMCAwIDAuMTY0OTc0IDI3Mi44NzMxNzR2NDcxLjgyNTc2OGEyNzYuNjYxNDczIDI3Ni42NjE0NzMgMCAwIDAgMjc0LjY4MTc4NSAyNzQuODQ2NzU5aDQzMC40MTcyODJsLTM4LjkzMzg3NC0xNTguMjEwMTA5ek0xMzc0LjIzMzc5MyAwLjAwNjEwNEg5NDAuNTE3MDI5bDM5LjU5Mzc3MSAxNTguODcwMDA1IDM3MC42OTY2NzkgODAuMzQyMzZhNzUuMDYzMTkgNzUuMDYzMTkgMCAwIDEgNTkuNzIwNjA0IDcyLjU4ODU4djM5OS4yMzcxODhhNzcuODY3NzQ5IDc3Ljg2Nzc0OSAwIDAgMS01OS43MjA2MDQgNzIuNTg4NThsLTM3MC42OTY2NzkgODAuMzQyMzYtMzkuNTkzNzcxIDE1OC44NzAwMDVIMTM3NC4yMzM3OTNhMjc1LjAxMTczMyAyNzUuMDExNzMzIDAgMCAwIDI3NS42NzE2MjktMjc1LjUwNjY1NVYyNzIuODczMTc0QTI3Ni4xNjY1NTEgMjc2LjE2NjU1MSAwIDAgMCAxMzc0LjIzMzc5MyAwLjAwNjEwNHoiLz4KCQk8cGF0aCBmaWxsPSIjRjc2RTA1IiBkPSJNNjY2LjMzMDE2NyA0OTAuNjM4OTEzaDMxNi4yNTUyNDR2MzguOTMzODc1SDY2Ni4zMzAxNjd6Ii8+Cgk8L2c+Cjwvc3ZnPg==" />
</div>
</div>
<div class="pure-u-2-3">
<h4 id="status_aliyun"><%:AliCloud Conn.%><br /><span id="_aliyun_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')" title="<%:TLS handshake test, latency for reference only%>">
<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 Conn.%><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')" title="<%:TLS handshake test, latency for reference only%>">
<div class="block pure-g">
<div class="pure-u-1-3">
<div class="img-con">
<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAHgAAAB4CAYAAAA5ZDbSAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAALEgAACxIB0t1+/AAAABx0RVh0U29mdHdhcmUAQWRvYmUgRmlyZXdvcmtzIENTNui8sowAAAAodEVYdGRhdGU6dGltZXN0YW1wADIwMjYtMDEtMzBUMTg6NDI6MjcrMDA6MDAo/9aaAAAAJXRFWHRkYXRlOmNyZWF0ZQAyMDI2LTAxLTMwVDE4OjQyOjI3KzAwOjAwDrdP+QAAACV0RVh0ZGF0ZTptb2RpZnkAMjAyNi0wMS0zMFQxODo0MjoyNyswMDowMH/q90UAACAASURBVHic3X17nN1Vde93/c45k0kmjwmQkISEDG8JCAMBRaQwtFKRagXb2tvez0eC1dvSViX1em9tez+E2l78+GhAW4sPINy2aq+2ERUQUDOItbwCAeSZgEMgAwmBTDLJZOac89vr/rHXWnvtc84kAcLDuzXMOb/H3muvx3c99v79DuH/k7ZyYE0fgP4iYDFx6EMoexFCLwG9zNwHStcWABg0AmCEiwJgDHGVngpFdaiJ5lAV1XXLB88eeX1msn8b7f2SN167fOCmvm50D1TK5oncLAeKUPYhhF4KgcEMyLwIYKaWOTIYBALLVSzXxRuIqQAVFQ4VbEdRWYeisq6sVG8DMPjLKPRfCgGvHFjT20Szf0ojvJfL8vwihD4KJSMwAIDB2TyISATJBKYoUGnM7M6DmOUPmPxxuRggAogQKhVwpbaOK9XBRiVc/4nBcwZfQxa87PaGFvBnz7h1oBbKC6lsnl+UzV4wQKxWGYVLHIVjN1E6RiAGACaQ2KgJ038mkAnb9wOOdu7vAQCuVIBKZSjUaoONCl33Rhb2G07AKwfW9BX1+oXM5SVFo96LwMbrFrQ1UwScsbUIXJE4dkIiQVERlj5buaByFuymDocF5pkrFQq12hBRsWKsFm775OC7hvYbM/ZDe8MI+LNn3DpQ4/LSot44C2UzWSgR2FmfXs+Jz2aFKgtKwmO13mSleh4GA97Co73G7mO/qjvEjEB2MwBCwWLmRACHWo1CV/eq8Ubjsk/e8cYQ9Osu4M+ecetAVQRLoSmM9j5RcDKDVvgYyUkVDDABzlLdOUGCzLqLKHO4+6Rb/e77SLQoHKjSKS0AEGo14q7uVeOVxmWvt0W/bgL+zOm39NcorKR6faAom1ChqGAzfyiNhNoOp+y8mnFUEzCI2oIwIDN/d0zRAPK3cOjQ7rcjrYkmcnMAAK52cVnruq5elq+bRb/mAl4xsLp3RmPqpZV6/RIqm3Zcg1oINqKIqQwBHDQQEpOBCAD21cnTrNBsO2/JmdsFHfx7PC2KpvAO7tyVBOydOgEADtXqU6FWvfLj/3HeFS+DZa+ovaYC/tyv3Hh+MVFeWzTrvcTEER8ZrRZLTMzEpH879ZWsJRo2I6GrCpldpA1K3939mbKAYxrcjh6u3wjORhN5JYBarwdsaUUFodo1NAE6+7W05tdEwCsGVvfOnOheXdQnzgKXNInJZCzRdKhTgNtZ4jQJdjs/ap2IAu2p74xGg+7oesnBdpY3C+bYhclfW7eVKkKte8XHf/bOyzpNY3+3V13Al59x08CUemM1NRu9EerY/J7X8yhQJoVcBijlvJ2adEUuC/LnDFGT2Cj/xjFoEp+vdLgAyu7RODrPmqyXhNMm5w4+X8YjEIgQalOG6ihedWsuXs3OP/+2H6ycsnt8DTUbvZG1mlWk8iDpQZUsAUGDFGUcgOz+eHlW8HCXMVJk7HNfDYIRmMEIpFCrvTIzWC+ScRhqfykdSvNgsCTeLPgd4Z2FHvUikPFShayoj/d1lY37Pvf2my98VZgv7VWx4BUDq3unj09ZXTTGB8AhDkRRcsrUrDqULDpZpECh9tnZK7qT5M+5qFdRQK5JFkeGGK2+OYuSvf9md42LETzSJARKKR0cKkhkTxqYBSIKXVNXfOKOd70qkL3fBXz5aTf1dYXGGjQm+qglCrU6MJCUO33nCF55Jcr5uvS5zXm6flxfk/5tuS4fU3ymAKrNoc1jdwgGO8QWcdqR+OSjWyC8KNCsdX+nNmXiouWDF+zXBY39KuDPnH5Lf6UcX031+uKWAMkKR4KqVpoCXDxjXioCeJul+CiWoYYAszjHXO04+VjLsbQcQt7vOz8b78zirfQpK4WaSsSxBR3AnPy6m63RDQjtmhIi0lPWuoZKqu1Xv7zfBPyZ02/pLxpja6hs9qYiL4xVvlYMxMkSiANLsYAdjNn3eGXGSP9dLK+l/Ng+s0xc5A5qLwqppDgcP5uliZj0u9BKRMxerdStsMeUlnl412OoFCcHBspa11BZdO03Ie8XAX/m9Fv6qb5rDZWNXg1D1J8mSzIfxYxgtWPSLDalsVm0alGUrQo47kVGw64lRUlgxvxpmLWgBzPmTwMAdM/oQq2nAhBh9NkxAMCOZ8dQH21gx/AuHh9tSB9uYpzrjDEtEajgG48LPT5tSrTBbFmwor0SJh2Gam2orEzZL0J+xQL+zKm39BN2raFmc5b0SIlaWCkh1YO9GXsfHEOQKDeBT4FrX/CQ7qLvlOBm4dI5mHP0LBx45EwsPHkOZsyfisDoWO5sY4CM39jVwNb1O7D18e3YdO/z2Pr4dmwfHlMwd8Ga6VtED6GJUIgfllBKCx46/3Qs/+uWM9J1QKjWntofQn5FAv7Mqbf0g3f9uCibvb5Wm+W6vkhvAUu8UiNTb8XkIS1DASsXcveMGg4fWECHn7UA8088AF0zauDQydZefiMivLB+O4bvewGP3rCRNz+2LQv8gJTveij2EXiUb5pn4ge8C2tfi5brykrXUKi8Mrh+2Sy5/LSb+opyYk3RrC9u7ScFrSrUkEXPGjxlF6ZbHeghy4cWLp2D0z58LA44ciZqPdW9T05LoMndJoanETtzwR0vigjrd1/9GJ5Z+zy2D+8yC84iezeOzST1ZmckS7PgLuvLBWIAEKpd63b38NkrXmZ0/bIEHIU7vqZoNhbrMQPhxEHSmZnf9Wu7trwHQ21lPLfUc497dx9O/C9H4MAjZyWB7Svl+3ht2wrWJPcVRHj0xo2482uPYPvwmPhhvytE0VanRknDMlC2qp0580SLSztAKLumXPfnd/7mRfs443xeL+emT5/6nTVFc2IgdkCcqWoHPzXJ0Ewt1yei4n3HvbsPb/nQm9Azd+rklL4UYfsx9iTQDn1maEDx+2Mm6F3I/K76F51bdC9Ay9JjFmFLxCUq7oIwAoNQVrtXfPKu91z2kuf5Um+4/C3fu7TSGL8UCGahGQO0Uwbrarpaq0aNzEgVpZb7GMCipXNw5vITksXuj9ZJEV6qcnQSPAh3X/Mo7vvmekzsaPiKVqpmWcEnx2q0fdfqWYo9bPZUoN7Vffb/uuM9gy+B4pcm4E+97bvnd02Mr45FiJSgt6Y2QjmnjGlPLXFtyowa3vahY3HC7x4Zg6Y9UKg+vaMC7Elwrec6cfxlIMKuzeO48+pH8PD3hji5GOuaJajMArX4PR9Q4duyENdCURuZqNFJK+64YGhf6ars64WXn3ZTX9Gof4NCmdKhZIhsnzWRj57VnY+TtqDRtuRE37Vw6UG44IozsPCtc9Og5qJyjnu4pGIyDWj5nvKZ9uuo5Xun1iJ42+8nx7p6qjj8zAXonlmj5x56EeVEsCVJVXKXaSDzwfmSYlrJytJDYnA5tUK1/h8Nf/O6Sahsa/u+mlROXEqh0ccFdBWGvbASrUpjHjQxy/VuC43UaPG2/3Ys3vcPZ6Ln4KmQGzKLSrU++eOse7I68Z5aezF7n27KWltaJjHjCe8/Av/1n8/BzAXTWoJMv3BifAND+AOdHrGlYVKmtQoeg4tm/az/fcp3L3mZZHduf3vqDcsqzV3XMEpykaEz25R7+OqVlptyzmgwwTRlRg0Dy/vxpvMOTVDrhDvZ3qw9T6glDXolTWnZF9hugfr6ziZ++Lf3YP3gpqRQzrf67u2+PKOD8ReAle6YwUV1pB6qJ61Yt3eo3itEr+hf3VelxrXg5mwd1q2Gk0V8Ue72JIGkgilUELxiYmIwzVrQg/dd8StY5CF5H3JS/c7I0omX5DdTAY3S98n6oPxv68JV2zH5W+kqcPQ5i1AUBZ6+53kfrpMuksREwmuQc3F6nOR/5soJBO6marX/x8P/uleo3itEVyt0KZX1xcwCqeIXdFEeKjQrXYmWUVa8j8flnlnzp/PvfOkszD5iZpYhMTguuMtYNmYH4SZd1wgvddRq9e0gwGlhXvPWbOw0LnM6l98LiwPsGCndsZ8QAt7ywTfh7X94XHRr4txC5JgBHdsHLQBmuXMM0Dw/wKiUjbM+tXT1+XuT3x71fkX/6r4uaj5JKBNSIM0JhPaNcaxMp1wSUtGduWAavf9LZ6Hn4Kmd4XeSvVWt67J5GZsnvW+Pk3d9+gJMp88vt5GEUndd+yj+4ysPCcGiC4RsMrFimS9F6vXCeHZ0EYquoXooTlqxbvIq1x4tuFaUlwJNW3QFgKCfpX5qwlWtButap86E1DHOmD+V3v+lszBtbvfkjPOlRZ0ckFmN1rP1sx9fGbAvTRXDgjY3jhs6+YOX0ZgZITBOvehYnPJ7R0t3gmUs/zMXHYg15GKwm1/8jIiCqTTa6KMq9hhwTUr1ilNWD3SV9R8zl3n1hZAveseDcacht9dS4Sz4A//06zjgiJnw0Bip78w/RedW/poMqH0C5ik6+MlOx/dlzMnoaLuPWubSEitUCsItf7sWP//eL9KNnp+dArosRTHb0qiduOgaaZaVwyaz4kktuMrlpYyStCOhF0DaFJdySJb/s9vG4IgB6Ozl/Zh9xAwwB+f/2i0nN2y2/r1/9Fz1Pq/d93ofkVu93qPXZfdTEor30Z4O7bcdiVL/LjQCAJSBceZH3oxZC3rsuBmFD9JYDVnq2/q/eJ4CALine truncated
</div>
</div>
<div class="pure-u-2-3">
<h4 id="status_github"><%:GitHub Conn.%><br /><span id="_github_status" class="red"><%:Touch Check%></span></h4>
</div>
</div>
</div>
<div class="pure-u-1-4 check" onclick="check_connect('youtube', 'https://www.youtube.com')" title="<%:TLS handshake test, latency for reference only%>">
<div class="block pure-g">
<div class="pure-u-1-3">
<div class="img-con">
<img src="data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjxzdmcKICAgeG1sOnNwYWNlPSJwcmVzZXJ2ZSIKICAgd2lkdGg9IjMwMCIKICAgaGVpZ2h0PSIzMDAiCiAgIHZlcnNpb249IjEuMSIKICAgaWQ9InN2ZzYiCiAgIHNvZGlwb2RpOmRvY25hbWU9IllvdVR1YmVfc29jaWFsX3JlZF9jaXJjbGVfKDIwMjQpLnN2ZyIKICAgaW5rc2NhcGU6dmVyc2lvbj0iMS4yLjIgKGIwYTg0ODY1NDEsIDIwMjItMTItMDEpIgogICB4bWxuczppbmtzY2FwZT0iaHR0cDovL3d3dy5pbmtzY2FwZS5vcmcvbmFtZXNwYWNlcy9pbmtzY2FwZSIKICAgeG1sbnM6c29kaXBvZGk9Imh0dHA6Ly9zb2RpcG9kaS5zb3VyY2Vmb3JnZS5uZXQvRFREL3NvZGlwb2RpLTAuZHRkIgogICB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciCiAgIHhtbG5zOnN2Zz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxkZWZzCiAgIGlkPSJkZWZzMTAiIC8+PHNvZGlwb2RpOm5hbWVkdmlldwogICBpZD0ibmFtZWR2aWV3OCIKICAgcGFnZWNvbG9yPSIjZmZmZmZmIgogICBib3JkZXJjb2xvcj0iIzAwMDAwMCIKICAgYm9yZGVyb3BhY2l0eT0iMC4yNSIKICAgaW5rc2NhcGU6c2hvd3BhZ2VzaGFkb3c9IjIiCiAgIGlua3NjYXBlOnBhZ2VvcGFjaXR5PSIwLjAiCiAgIGlua3NjYXBlOnBhZ2VjaGVja2VyYm9hcmQ9IjAiCiAgIGlua3NjYXBlOmRlc2tjb2xvcj0iI2QxZDFkMSIKICAgc2hvd2dyaWQ9ImZhbHNlIgogICBpbmtzY2FwZTp6b29tPSIxLjI1IgogICBpbmtzY2FwZTpjeD0iMTQyLjQiCiAgIGlua3NjYXBlOmN5PSIxNTAuOCIKICAgaW5rc2NhcGU6d2luZG93LXdpZHRoPSIxMTg3IgogICBpbmtzY2FwZTp3aW5kb3ctaGVpZ2h0PSI1ODIiCiAgIGlua3NjYXBlOndpbmRvdy14PSIwIgogICBpbmtzY2FwZTp3aW5kb3cteT0iMCIKICAgaW5rc2NhcGU6d2luZG93LW1heGltaXplZD0iMSIKICAgaW5rc2NhcGU6Y3VycmVudC1sYXllcj0ic3ZnNiIgLz4KICA8cmVjdAogICB3aWR0aD0iMzAwIgogICBoZWlnaHQ9IjMwMCIKICAgZmlsbD0iI2ZmZiIKICAgcnk9IjE1MCIKICAgaWQ9InJlY3QyIgogICBzdHlsZT0iZmlsbDojZmYwMDMzO2ZpbGwtb3BhY2l0eToxIiAvPgogIDxwYXRoCiAgIGZpbGw9InJlZCIKICAgZD0iTTE0OS45Mzc1MDU4NyA3OS4yMjI2NzM2NHMtNjMuMjE4ODUuMDAwMDU5LTc5LjA4NTkzOTI2IDQuMTIzMDQ3MDdjLTguNDk1Nzg3MTQgMi4zNzM4MDIwNC0xNS40OTE0MDgyNSA5LjM2OTUwMDE1LTE3Ljg2NTIzNDI5IDE3Ljk5MDIzNzI5LTQuMTIyOTUzMDYgMTUuODY3MTAwMjUtNC4xMjMwNDcwNiA0OC43MjY1NjA3OC00LjEyMzA0NzA2IDQ4LjcyNjU2MDc4cy4wMDAwOTQgMzIuOTg0MjAwNTIgNC4xMjMwNDcwNiA0OC42MDE1NjA3N2MyLjM3MzgyNjA0IDguNjIwNjIwMTQgOS4yNDQ1MDYxNSAxNS40OTEzODAyNSAxNy44NjUyMzQyOSAxNy44NjUyNDAyOSAxNS45OTIwMzAyNSA0LjI0Nzg4MDA3IDc5LjA4NTkzOTI2IDQuMjQ4MDQwMDcgNzkuMDg1OTM5MjYgNC4yNDgwNDAwN3M2My4zNDQxODEwMS0uMDAwMDUgNzkuMjEwOTQxMjctNC4xMjMwNDAwN2M4LjYyMDc5MDE0LTIuMzczODEwMDQgMTUuNDkxMzMwMjUtOS4xMTk2NjAxNSAxNy43NDAyMzAyOC0xNy44NjUyNDAyOSA0LjI0NzkzMDA3LTE1Ljc0MjMyMDI1IDQuMjQ4MDUwMDctNDguNjAxNTYwNzcgNC4yNDgwNTAwNy00OC42MDE1NjA3N3MuMTI0ODQtMzIuOTg0NDYwNTMtNC4yNDgwNTAwNy00OC44NTE1NjA3OGMtMi4yNDg5MDAwMy04LjYyMDczNzE0LTkuMTE5NDQwMTQtMTUuNDkxMzM0MjUtMTcuNzQwMjMwMjgtMTcuNzQwMjM3MjktMTUuODY2NzYwMjYtNC4zNzI4NDcwNy03OS4yMTA5NDEyNy00LjM3MzA0NzA3LTc5LjIxMDk0MTI3LTQuMzczMDQ3MDd6bS0yMC4xMTUyMzAzMiA0MC40ODA0NjQ2NSA1Mi41OTk2MTA4NCAzMC4zNTkzODA0OS01Mi41OTk2MTA4NCAzMC4yMzQzODA0OHYtNjAuNTkzNzYwOTd6IgogICBpZD0icGF0aDQiCiAgIHN0eWxlPSJmaWxsOiNmZmZmZmY7ZmlsbC1vcGFjaXR5OjEiIC8+Cjwvc3ZnPg==" />
</div>
</div>
<div class="pure-u-2-3">
<h4 id="status_youtube"><%:YouTube Conn.%><br /><span id="_youtube_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) {
var status_dns = document.getElementById('status_dns');
var status_haproxy = document.getElementById('status_haproxy');
if (data) {
if (true) {
var status_tcp_node = document.getElementById('status_tcp_node');
if (status_tcp_node) {
var text = 'TCP<br />';
if (data["tcp_node_status"])
text += '<span class="green"><%:RUNNING%></span>';
else
text += '<span class="red"><%:NOT RUNNING%></span>';
status_tcp_node.innerHTML = text;
}
}
if (true) {
var status_udp_node = document.getElementById('status_udp_node');
if (status_udp_node) {
var text = 'UDP<br />';
if (data["udp_node_status"])
text += '<span class="green"><%:RUNNING%></span>';
else
text += '<span class="red"><%:NOT RUNNING%></span>';
status_udp_node.innerHTML = text;
}
}
if (data.dns_mode_status) {
status_dns.innerHTML = 'DNS<br /><span class="green"><%:RUNNING%></span>';
} else {
status_dns.innerHTML = 'DNS<br /><span class="red"><%:NOT RUNNING%></span>';
}
if (status_haproxy) {
if (data.haproxy_status === "-1") {
status_haproxy.innerHTML = 'HAPROXY<br /><span class="red"><%:UNINSTALLED%></span>';
} else if (data.haproxy_status === "0") {
status_haproxy.innerHTML = 'HAPROXY<br /><span class="green"><%:RUNNING%></span>';
} else {
status_haproxy.innerHTML = 'HAPROXY<br /><span class="red"><%:NOT RUNNING%></span>';
}
}
}
});
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.passwall.api"
local appname = api.appname
-%>
<script src="<%=resource%>/view/<%=appname%>/Sortable.min.js?v=1.15.7"></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.passwall.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-passwall-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-passwall-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,45 @@
<%
local api = require "luci.passwall.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*="<%=self.config%>"][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*="<%=self.config%>"][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,80 @@
<%
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.<%=self.config%>.' + 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%>");
}
});
}
function fetchCertSha256(el) {
if (el.getAttribute("data-loading") === "1") return;
el.setAttribute("data-loading", "1");
el.style.pointerEvents = "none";
el.style.color = "red";
XHR.get('<%=api.url("fetch_certsha256")%>', { id: node_id }, function(x, res) {
el.setAttribute("data-loading", "0");
el.style.pointerEvents = "";
el.style.color = "";
if (!x || x.status !== 200) {
alert("<%:Error%>");
return;
}
if (!res || res.code !== 1 || !res.data) {
alert(res && res.msg ? res.msg : "<%:Fetch Failed%> !");
return;
}
const inputs = document.querySelectorAll('input[id$=".xray_tls_pinSHA256"]');
inputs.forEach(function(input) {
input.value = res.data;
});
});
}
//]]>
</script>
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,556 @@
<%
local api = require "luci.passwall.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; // 分片发送以突破uhttpd的限制,每块1000字符
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-passwall-nodes > .cbi-tabcontainer[style*="display:block"], #cbi-passwall-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-passwall-nodes > .cbi-tabcontainer[style*="display:block"], #cbi-passwall-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%>");
}
});
}
}
}
//自定义分组下拉列表事件
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);
});
// 从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);
// 点击外部时自动收起
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.passwall.api"
-%>
<script src="<%=resource%>/view/<%=api.appname%>/Sortable.min.js?v=1.15.7"></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,338 @@
<%
local api = require "luci.passwall.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?v=1.15.7"></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$="-gfwlist_update"],
div.cbi-value[id$="-chnroute_update"],
div.cbi-value[id$="-chnroute6_update"],
div.cbi-value[id$="-chnlist_update"],
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="gfwlist" value="1" />
gfwlist
</label>
<label>
<input class="cbi-input-checkbox" type="checkbox" name="chnroute" value="1" />
chnroute
</label>
<label>
<input class="cbi-input-checkbox" type="checkbox" name="chnroute6" value="1" />
chnroute6
</label>
<label>
<input class="cbi-input-checkbox" type="checkbox" name="chnlist" value="1" />
chnlist
</label>
<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 = [
"gfwlist_update","chnroute_update","chnroute6_update",
"chnlist_update","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 geo2ruleFlag = () => {
const geoEl = document.querySelector('input[type="checkbox"][name*="passwall"][name*="geo2rule"]');
if (!geoEl) return;
const updateCheckboxes = (disabled) => {
flags.filter(flag => !flag.includes('geo')).forEach(flag => {
const custom = document.querySelector(`.cbi-input-checkbox[name="${flag.replace('_update','')}"]`);
if (custom) {
//if (disabled) custom.checked = true;
custom.parentElement.style.display = disabled ? 'none' : '';
}
});
};
updateCheckboxes(geoEl.checked);
geoEl.addEventListener("change", () => {
geoEl.blur();
updateCheckboxes(geoEl.checked);
});
};
const bindAll = () => {
bindFlags();
geo2ruleFlag();
};
const target = document.querySelector('form') || document.body;
const observer = new MutationObserver(() => bindAll() ? observer.disconnect() : 0);
observer.observe(target, { childList: true, subtree: true });
const timer = setInterval(() => bindAll() ? (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...%>';
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) {
if(type == 'geoip' && (dom.name === 'chnroute' || dom.name === 'chnroute6')) {
checkBoxList.push(dom.name);
}
if(type == 'geosite' && (dom.name === 'gfwlist' || dom.name === 'chnlist')) {
checkBoxList.push(dom.name);
}
}
}
XHR.get('<%=api.url("rollback_rules")%>', {
type: type,
rules: checkBoxList.join(",")
},
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;
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.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,111 @@
<%
local api = require "luci.passwall.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-passwall-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-passwall-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,47 @@
<%
local api = require "luci.passwall.api"
local translate = luci.i18n.translate
local total_lines_text = translate("Total Lines")
-%>
<script type="text/javascript">
//<![CDATA[
function read_gfw() {
fetch('<%= api.url("read_rulelist") %>?type=gfw')
.then(response => response.text())
.then(data => {
var total_lines = data.split("\n").filter(line => line.trim() !== "").length;
var textarea = document.getElementById('gfw_textarea');
textarea.innerHTML = data;
//textarea.scrollTop = textarea.scrollHeight;
var totalLinesLabel = document.getElementById('gfw_total_lines');
totalLinesLabel.innerHTML = "<%= total_lines_text %> " + total_lines;
})
}
function read_chn() {
fetch('<%= api.url("read_rulelist") %>?type=chn')
.then(response => response.text())
.then(data => {
var total_lines = data.split("\n").filter(line => line.trim() !== "").length;
var textarea = document.getElementById('chn_textarea');
textarea.innerHTML = data;
//textarea.scrollTop = textarea.scrollHeight;
var totalLinesLabel = document.getElementById('chn_total_lines');
totalLinesLabel.innerHTML = "<%= total_lines_text %> " + total_lines;
})
}
function read_chnroute() {
fetch('<%= api.url("read_rulelist") %>?type=chnroute')
.then(response => response.text())
.then(data => {
var total_lines = data.split("\n").filter(line => line.trim() !== "").length;
var textarea = document.getElementById('chnroute_textarea');
textarea.innerHTML = data;
//textarea.scrollTop = textarea.scrollHeight;
var totalLinesLabel = document.getElementById('chnroute_total_lines');
totalLinesLabel.innerHTML = "<%= total_lines_text %> " + total_lines;
})
}
//]]>
</script>
@@ -0,0 +1,27 @@
<%
local api = self.api
-%>
<script type="text/javascript">
//<![CDATA[
document.addEventListener("DOMContentLoaded", function () {
waitForElement('select[name*="<%=self.config%>"][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({
enable: getOption("enable").value,
remarks: getOption("remarks").value,
type: getOption("type").value,
protocol: ''
});
}
}
});
});
});
//]]>
</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("server_user")%>/' + 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.<%=self.config%>.' + 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("server_user_update")%>', {
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>
@@ -0,0 +1,48 @@
<%
local api = require "luci.passwall.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.passwall.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-passwall_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 = "";
//添加"日志"按钮
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\')"/>&nbsp;&nbsp;';
td.innerHTML = new_div + td.innerHTML;
}
catch(err) {
console.error(err);
}
}
//]]>
</script>
@@ -0,0 +1,45 @@
<%
local api = require "luci.passwall.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>
@@ -1,128 +0,0 @@
<style>
.pure-img {
max-height: 100%;
width: auto;
}
.flag .pure-img {
max-height: none;
margin-top: -0.34rem;
}
.status-bar {
position: fixed;
bottom: 0;
right: 0;
box-shadow: 0 0 2rem 0 rgba(136, 152, 170, .3);
color: #525f7f;
background: #fff;
z-index: 5;
box-sizing: border-box;
}
.status-bar .inner {
margin: 0.5em;
}
.status-bar .inner .flag {
height: 2.6em;
display: block;
float: left;
margin-right: 1em;
}
.status-bar .inner .status-info {
font-weight: bold;
}
.status-bar .icon-con {
height: 2.6em;
text-align: right;
}
#cbi-passwall+.cbi-page-actions.control-group.fixed {
bottom: 3.3rem;
}
footer{
display:block !important;
}
@media screen and (max-width: 700px) {
.status-bar .icon-con {
height: 2.5em;
}
}
</style>
<div class="status-bar">
<div class="inner">
<div class="pure-g">
<div class="pure-u-1-2">
<span class="flag"><img src="/luci-static/passwall/flags/loading.svg" class="pure-img"></span> <span
class="status-info">获取中...</span>
</div>
<div class="pure-u-1-2">
<div class="icon-con">
<img src="/luci-static/passwall/img/site_icon1_01.png" class="pure-img i1">
<img src="/luci-static/passwall/img/site_icon1_02.png" class="pure-img i2">
<img src="/luci-static/passwall/img/site_icon1_03.png" class="pure-img i3">
<img src="/luci-static/passwall/img/site_icon1_04.png" class="pure-img i4">
</div>
</div>
</div>
</div>
</div>
<script>
const _ASSETS = '/luci-static/passwall/';
const CHECK_IP_URL = '<%=url([[admin]], [[services]], [[passwall]], [[ip]])%>';
let wW = window.innerWidth;
function resize() {
wW = window.innerWidth;
let lw = document.querySelector(".main-left").offsetWidth;
let statusBar = document.querySelector(".status-bar");
statusBar.style.width = (wW - lw) + 'px';
let flagElement = statusBar.querySelector(".flag");
flagElement.style.width = (flagElement.offsetHeight / 3 * 4) + 'px';
document.querySelectorAll(".flag-icon").forEach(function(el) {
if (el.offsetHeight < 60) {
el.parentElement.style.height = '60px';
el.style.width = '60px';
} else {
el.style.width = el.offsetHeight + 'px';
}
});
}
function write_status(data) {
document.querySelector(".flag img").src = _ASSETS + "flags/" + data.flag + ".svg";
document.querySelector(".status-info").innerHTML = data.ip + "<br>" + data.country + "&nbsp;" + data.isp;
document.querySelector(".i1").src = data.baidu ? _ASSETS + "img/site_icon_01.png" : _ASSETS + "img/site_icon1_01.png";
document.querySelector(".i2").src = data.taobao ? _ASSETS + "img/site_icon_02.png" : _ASSETS + "img/site_icon1_02.png";
document.querySelector(".i3").src = data.google ? _ASSETS + "img/site_icon_03.png" : _ASSETS + "img/site_icon1_03.png";
document.querySelector(".i4").src = data.youtube ? _ASSETS + "img/site_icon_04.png" : _ASSETS + "img/site_icon1_04.png";
setTimeout(function() {
let event = new Event('iploaded');
document.body.dispatchEvent(event);
}, 200);
}
XHR.poll(5, CHECK_IP_URL, null,
function (x, data) {
write_status(data);
}
);
document.addEventListener('DOMContentLoaded', function() {
setTimeout("resize()",100)
fetch(CHECK_IP_URL)
.then(response => response.json())
.then(data => {
write_status(data);
})
.catch(error => console.error('Error:', error));
});
window.addEventListener('resize', resize);
</script>
File diff suppressed because it is too large. Load diff
+1
View File
@@ -0,0 +1 @@
zh-cn
@@ -0,0 +1,4 @@
config global 'global'
option enable '0'
@@ -0,0 +1,35 @@
#!/bin/sh
. /usr/share/passwall/utils.sh
[ "$ACTION" = "ifup" ] || [ "$ACTION" = "ifupdate" ] || exit 0
([ "$(get_cache_var "ENABLED_DEFAULT_ACL")" = "1" ] || [ "$(get_cache_var "ENABLED_ACLS")" = "1" ]) && [ -f ${LOCK_PATH}/${CONFIG}_ready.lock ] && {
default_device=$(ip route show default 2>/dev/null | awk -F 'dev ' '{print $2}' | awk '{print $1}' | head -n1)
default6_device=$(ip -6 route show default 2>/dev/null | awk -F 'dev ' '{print $2}' | awk '{print $1}' | head -n1)
( [ "$default_device" = "$DEVICE" ] || [ "$default6_device" = "$DEVICE" ] ) && {
if [ "$ACTION" = "ifupdate" ]; then
USE_TABLES=$(get_cache_var "USE_TABLES")
[ -n "$USE_TABLES" ] && { ${APP_PATH}/${USE_TABLES}.sh update_wan_sets >/dev/null 2>&1 & }
exit 0
fi
[ ! -d ${LOCK_PATH} ] && mkdir -p ${LOCK_PATH}
LOCK_FILE="${LOCK_PATH}/${CONFIG}_ifup.lock"
if [ -s ${LOCK_FILE} ]; then
SPID=$(cat ${LOCK_FILE})
if [ -e /proc/${SPID}/status ]; then
exit 1
fi
cat /dev/null > ${LOCK_FILE}
fi
echo $$ > ${LOCK_FILE}
/etc/init.d/${CONFIG} restart >/dev/null 2>&1 &
logger -p notice -t network -s "${CONFIG}: restart when $INTERFACE ifup"
rm -rf ${LOCK_FILE}
}
}
+86
View File
@@ -0,0 +1,86 @@
#!/bin/sh /etc/rc.common
START=99
STOP=15
ROOT="${IPKG_INSTROOT:-}"
. "$ROOT/usr/share/passwall/utils.sh"
APP_FILE=${APP_PATH}/app.sh
LOCK_FILE_DIR=/var/lock
LOCK_FILE=${LOCK_FILE_DIR}/${CONFIG}.lock
set_lock() {
[ ! -d "$LOCK_FILE_DIR" ] && mkdir -p $LOCK_FILE_DIR
exec 999>"$LOCK_FILE"
flock -xn 999
}
unset_lock() {
flock -u 999
rm -rf "$LOCK_FILE"
}
unlock() {
failcount=1
while [ "$failcount" -le 10 ]; do
if [ -f "$LOCK_FILE" ]; then
let "failcount++"
sleep 1s
[ "$failcount" -ge 10 ] && unset_lock
else
break
fi
done
}
boot_func() {
local delay=$(uci -q get ${CONFIG}.@global_delay[0].start_delay || echo 1)
if [ "$delay" -gt 0 ]; then
echolog "执行启动延时 $delay 秒后再启动!"
sleep $delay
fi
restart boot
touch ${LOCK_FILE_DIR}/${CONFIG}_ready.lock
}
boot() {
boot_func >/dev/null 2>&1 &
}
start() {
set_lock
[ $? == 1 ] && echolog "脚本已经在运行,不重复运行,退出." && exit 0
$APP_FILE start
unset_lock
}
stop() {
unlock
set_lock
[ $? == 1 ] && echolog "停止脚本等待超时,不重复运行,退出." && exit 0
$APP_FILE stop
unset_lock
}
restart() {
set_lock
[ $? == 1 ] && echolog "脚本已经在运行,不重复运行,退出." && exit 0
rule_lock=${LOCK_FILE_DIR}/${CONFIG}_rule_update.lock
sub_lock=${LOCK_FILE_DIR}/${CONFIG}_subscribe.lock
for i in $(seq 1 300); do
if [ ! -f "$rule_lock" ] && [ ! -f "$sub_lock" ]; then
break
fi
sleep 2
done
rm -f "$rule_lock" "$sub_lock"
$APP_FILE stop
$APP_FILE start $1
unset_lock
}
reload() {
echo "Warning: This service does not support configuration reloading"
echo "Performing full restart instead..."
restart
}
+22
View File
@@ -0,0 +1,22 @@
#!/bin/sh /etc/rc.common
START=99
start() {
lua /usr/lib/lua/luci/passwall/server_app.lua start
}
stop() {
lua /usr/lib/lua/luci/passwall/server_app.lua stop
}
restart() {
stop
start
}
reload() {
echo "Warning: This service does not support configuration reloading"
echo "Performing full restart instead..."
restart
}
+58
View File
@@ -0,0 +1,58 @@
#!/bin/sh
if [ -e "/etc/config/ucitrack" ]; then
uci -q batch <<-EOF
delete ucitrack.@passwall[-1]
add ucitrack passwall
set ucitrack.@passwall[-1].init=passwall
delete ucitrack.@passwall_server[-1]
add ucitrack passwall_server
set ucitrack.@passwall_server[-1].init=passwall_server
commit ucitrack
EOF
fi
uci -q batch <<-EOF
delete firewall.passwall
set firewall.passwall=include
set firewall.passwall.type='script'
set firewall.passwall.path='/var/etc/passwall.include'
delete firewall.passwall_server
set firewall.passwall_server=include
set firewall.passwall_server.type='script'
set firewall.passwall_server.path='/var/etc/passwall_server.include'
set dhcp.@dnsmasq[0].localuse=1
commit dhcp
set uhttpd.main.max_requests=50
commit uhttpd
EOF
if [ -x "/sbin/fw3" ]; then
uci -q set firewall.passwall.reload='1'
uci -q set firewall.passwall_server.reload='1'
else
uci -q delete firewall.passwall.reload
uci -q delete firewall.passwall.fw4_compatible
uci -q delete firewall.passwall_server.reload
uci -q delete firewall.passwall_server.fw4_compatible
fi
uci commit firewall
[ ! -s "/etc/config/passwall" ] && cp -f /usr/share/passwall/0_default_config /etc/config/passwall
chmod +x /usr/share/passwall/*.sh
[ "$(uci -q get passwall.@global_xray[0].sniffing)" == "1" ] && [ "$(uci -q get passwall.@global_xray[0].route_only)" != "1" ] && uci -q set passwall.@global_xray[0].sniffing_override_dest=1
uci -q delete passwall.@global_xray[0].sniffing
uci -q delete passwall.@global_xray[0].route_only
uci -q commit passwall
sed -i "s#add_from#group#g" /etc/config/passwall 2>/dev/null
rm -f /tmp/luci-indexcache /tmp/luci-indexcache.*
rm -rf /tmp/luci-modulecache/
killall -HUP rpcd 2>/dev/null
exit 0
@@ -0,0 +1,255 @@
config global
option enabled '0'
option socks_enabled '0'
option tcp_node_socks_port '1070'
option filter_proxy_ipv6 '1'
option dns_shunt 'chinadns-ng'
option dns_mode 'tcp'
option remote_dns '1.1.1.1'
list smartdns_remote_dns 'https://1.1.1.1/dns-query'
option use_default_dns 'direct'
option chinadns_ng_default_tag 'none'
option dns_redirect '1'
option use_direct_list '1'
option use_proxy_list '1'
option use_block_list '1'
option use_gfw_list '1'
option chn_list 'direct'
option tcp_proxy_mode 'proxy'
option udp_proxy_mode 'proxy'
option localhost_proxy '1'
option client_proxy '1'
option acl_enable '0'
option log_tcp '0'
option log_udp '0'
option loglevel 'error'
option log_chinadns_ng '0'
config global_haproxy
option balancing_enable '0'
config global_delay
option start_daemon '1'
option start_delay '60'
config global_forwarding
option tcp_no_redir_ports 'disable'
option udp_no_redir_ports 'disable'
option tcp_proxy_drop_ports 'disable'
option udp_proxy_drop_ports '443'
option tcp_redir_ports '22,25,53,80,143,443,465,587,853,873,993,995,5222,8080,8443,9418'
option udp_redir_ports '1:65535'
option accept_icmp '0'
option prefer_nft '1'
option tcp_proxy_way 'redirect'
option ipv6_tproxy '0'
config global_xray
option sniffing_override_dest '0'
config global_singbox
config global_other
option auto_detection_time 'tcping'
option show_node_info '0'
config global_rules
option auto_update '0'
option chnlist_update '1'
option chnroute_update '1'
option chnroute6_update '1'
option gfwlist_update '1'
option geosite_update '0'
option geoip_update '0'
list gfwlist_url 'https://cdn.jsdelivr.net/gh/Loyalsoldier/v2ray-rules-dat@release/gfw.txt'
list chnroute_url 'https://ispip.clang.cn/all_cn.txt'
list chnroute_url 'https://cdn.jsdelivr.net/gh/gaoyifan/china-operator-ip@ip-lists/china.txt'
list chnroute6_url 'https://ispip.clang.cn/all_cn_ipv6.txt'
list chnroute6_url 'https://cdn.jsdelivr.net/gh/gaoyifan/china-operator-ip@ip-lists/china6.txt'
list chnlist_url 'https://cdn.jsdelivr.net/gh/felixonmars/dnsmasq-china-list/accelerated-domains.china.conf'
list chnlist_url 'https://cdn.jsdelivr.net/gh/felixonmars/dnsmasq-china-list/apple.china.conf'
option v2ray_location_asset '/usr/share/v2ray/'
option geoip_url 'https://github.com/Loyalsoldier/geoip/releases/latest/download/geoip.dat'
option geosite_url 'https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat'
config global_app
option geoview_file '/usr/bin/geoview'
option sing_box_file '/usr/bin/sing-box'
option xray_file '/usr/bin/xray'
option hysteria_file '/usr/bin/hysteria'
config global_subscribe
option filter_keyword_mode '1'
list filter_discard_list '距离下次重置剩余'
list filter_discard_list '套餐到期'
list filter_discard_list '过期时间'
list filter_discard_list '剩余流量'
list filter_discard_list 'QQ群'
list filter_discard_list '官网'
config nodes 'myshunt'
option remarks '分流总节点'
option type 'Xray'
option protocol '_shunt'
option DirectGame '_direct'
option ProxyGame '_default'
option AIGC '_default'
option Streaming '_default'
option Proxy '_default'
option Direct '_direct'
option default_node '_direct'
option domainStrategy 'IPOnDemand'
config shunt_rules 'DirectGame'
option remarks 'DirectGame'
option network 'tcp,udp'
option domain_list '# steam直连域名获取国内CDN走国内线路下载
cm.steampowered.com
steamserver.net
# steam国内CDN华为云
steampipe.steamcontent.tnkjmec.com
# steam国内CDN白山云
st.dl.eccdnx.com
st.dl.bscstorage.net
st.dl.pinyuncloud.com
# steam国内CDN新流云(原金山云)(支持ipv6)
dl.steam.clngaa.com
# steam国内CDN网宿
cdn.mileweb.cs.steampowered.com.8686c.com
cdn-ws.content.steamchina.com
# steam国内CDN腾讯云 (蒸汽中国独占)
cdn-qc.content.steamchina.com
# steam国内CDN阿里云(支持ipv6)
cdn-ali.content.steamchina.com
xz.pphimalayanrt.com
lv.queniujq.cn
alibaba.cdn.steampipe.steamcontent.com
# 国内游戏geosite域名
geosite:category-games@cn'
option ip_list '# steam直连IP
45.121.184.0/24
103.10.124.0/23
103.28.54.0/24
146.66.152.0/24
146.66.155.0/24
153.254.86.0/24
155.133.224.0/22
155.133.230.0/24
155.133.232.0/23
155.133.234.0/24
155.133.236.0/22
155.133.240.0/23
155.133.244.0/23
155.133.246.0/24
155.133.248.0/21
162.254.192.0/21
185.25.182.0/23
190.217.32.0/22
192.69.96.0/22
205.196.6.0/24
208.64.200.0/22
208.78.164.0/22
205.185.194.0/24'
config shunt_rules 'ProxyGame'
option remarks 'ProxyGame'
option network 'tcp,udp'
option domain_list '# steam 商店/客服/聊天/网页布局/API/二维码/Google云同步 代理URL
steamcommunity.com
www.steamcommunity.com
store.steampowered.com
checkout.steampowered.com
api.steampowered.com
help.steampowered.com
login.steampowered.com
store.akamai.steamstatic.com
steambroadcast.akamaized.net
steamvideo-a.akamaihd.net
steamusercontent-a.akamaihd.net
steamstore-a.akamaihd.net
steamcommunity-a.akamaihd.net
steamcdn-a.akamaihd.net
steamuserimages-a.akamaihd.net
community.akamai.steamstatic.com
avatars.akamai.steamstatic.com
community.steamstatic.com
cdn.akamai.steamstatic.com
avatars.steamstatic.com
shared.akamai.steamstatic.com
clan.akamai.steamstatic.com
cdn.cloudflare.steamstatic.com
community.cloudflare.steamstatic.com
store.cloudflare.steamstatic.com
avatars.cloudflare.steamstatic.com
clan.cloudflare.steamstatic.com
shared.cloudflare.steamstatic.com
steam-chat.com
steamcloud-ugc.storage.googleapis.com
steamcloud-eu-ams.storage.googleapis.com
steamcloud-eu-fra.storage.googleapis.com
steamcloud-finland.storage.googleapis.com
steamcloud-saopaulo.storage.googleapis.com
steamcloud-singapore.storage.googleapis.com
steamcloud-sydney.storage.googleapis.com
steamcloud-taiwan.storage.googleapis.com
steamcloud-eu.storage.googleapis.com
domain:blizzard.com
geosite:category-games'
config shunt_rules 'AIGC'
option remarks 'AIGC'
option network 'tcp,udp'
option domain_list 'geosite:category-ai-!cn
geosite:apple-intelligence'
config shunt_rules 'Streaming'
option remarks 'Streaming'
option network 'tcp,udp'
option domain_list 'geosite:netflix
geosite:disney'
config shunt_rules 'Proxy'
option remarks 'Proxy'
option network 'tcp,udp'
option domain_list 'geosite:geolocation-!cn'
option ip_list '149.154.160.0/20
91.108.4.0/22
91.108.56.0/24
109.239.140.0/24
67.198.55.0/24
8.8.4.4
8.8.8.8
208.67.222.222
208.67.220.220
1.1.1.1
1.1.1.2
1.0.0.1
9.9.9.9
149.112.112.112
2001:67c:4e8::/48
2001:b28:f23c::/48
2001:b28:f23d::/48
2001:b28:f23f::/48
2001:b28:f242::/48
2001:4860:4860::8888
2001:4860:4860::8844
2606:4700:4700::1111
2606:4700:4700::1001'
config shunt_rules 'Direct'
option remarks 'Direct'
option network 'tcp,udp'
option domain_list 'geosite:cn'
option ip_list '223.5.5.5/32
223.6.6.6/32
119.29.29.29/32
180.76.76.76/32
114.114.114.114/32
114.114.115.115/32
1.12.12.12/32
120.53.53.53/32
geoip:cn
geoip:private'
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,560 @@
#!/usr/bin/lua
-- Copyright(c): lwb1978 2025–2027
local api = require("luci.passwall.api")
local urlencode = api.UrlEncode
local base64 = api.base64Encode
local json = api.jsonc
local function host_format(host)
if not host then return "" end
local str = host:match("%[(.-)%]") or host
if api.datatypes.ip6addr(str) then
return "[" .. str .. "]"
end
return host
end
local function build_alpn(alpn) -- 排序+去重
if not alpn then return nil end
local seen = {}
local order = { "h3", "h2", "http/1.1" }
if type(alpn) == "table" then
for _, v in ipairs(alpn) do
if v then seen[v] = true end
end
else
seen[tostring(alpn)] = true
end
local t = {}
for _, v in ipairs(order) do
if seen[v] then table.insert(t, v) end
end
if #t == 0 then return nil end
return table.concat(t, ",")
end
local function build_common(node)
local o = {}
o.server = host_format(node.server)
o.port = node.port
o.name = node.name
-- ===== TLS =====
o.tls = {}
if node["reality-opts"] then
o.tls.security = "reality"
o.tls.pbk = node["reality-opts"]["public-key"]
o.tls.sid = node["reality-opts"]["short-id"]
elseif node.tls then
o.tls.security = "tls"
end
o.tls.sni = node.servername or node.sni
o.tls.fp = node["client-fingerprint"]
o.tls.pcs = node.fingerprint
o.tls.insecure = node["skip-cert-verify"] == true
o.tls.alpn = build_alpn(node.alpn)
local ech_opts = node["ech-opts"]
if ech_opts and ech_opts.enable == true then
if ech_opts["query-server-name"] then
o.tls.ech = ech_opts["query-server-name"] .. "+https://223.5.5.5/dns-query"
elseif ech_opts.config then
o.tls.ech = ech_opts.config
end
end
-- ===== transport =====
o.transport = {}
local net = node.network or "tcp"
o.transport.type = net
local function get_first(v)
if type(v) == "table" then return v[1] end
return v
end
if net == "ws" then
local opts = node["ws-opts"]
if opts then
o.transport.path = opts.path
o.transport.host = opts.headers and opts.headers.Host
end
elseif net == "grpc" then
local opts = node["grpc-opts"]
if opts then
o.transport.serviceName = opts["grpc-service-name"]
end
elseif net == "http" then
local opts = node["http-opts"]
if opts then
o.transport.host = get_first(opts.host)
o.transport.path = get_first(opts.path)
end
elseif net == "h2" then
local opts = node["h2-opts"]
if opts then
o.transport.host = get_first(opts.host)
o.transport.path = opts.path
end
elseif net == "xhttp" then
local opts = node["xhttp-opts"]
if opts then
o.transport.host = opts.host
o.transport.path = opts.path
o.transport.mode = opts.mode
local extra = {}
-- headers
if opts.headers then
extra.headers = opts.headers
end
if opts["x-padding-bytes"] then
extra.xPaddingBytes = opts["x-padding-bytes"]
end
if opts["no-grpc-header"] ~= nil then
extra.noGRPCHeader = opts["no-grpc-header"]
end
if opts["sc-max-each-post-bytes"] then
extra.scMaxEachPostBytes = opts["sc-max-each-post-bytes"]
end
if opts["sc-min-posts-interval-ms"] then
extra.scMinPostsIntervalMs = opts["sc-min-posts-interval-ms"]
end
-- xmux
if opts["reuse-settings"] then
local r = opts["reuse-settings"]
local xmux = {}
if r["max-concurrency"] then xmux.maxConcurrency = r["max-concurrency"] end
if r["max-connections"] then xmux.maxConnections = tonumber(r["max-connections"]) end
if r["c-max-reuse-times"] then xmux.cMaxReuseTimes = tonumber(r["c-max-reuse-times"]) end
if r["h-max-request-times"] then xmux.hMaxRequestTimes = r["h-max-request-times"] end
if r["h-max-reusable-secs"] then xmux.hMaxReusableSecs = r["h-max-reusable-secs"] end
if r["h-keep-alive-period"] then xmux.hKeepAlivePeriod = tonumber(r["h-keep-alive-period"]) end
if next(xmux) then extra.xmux = xmux end
end
-- download-settings
if opts["download-settings"] then
local d = opts["download-settings"]
local ds = {}
if d.server then ds.address = d.server end
if d.port then ds.port = d.port end
ds.network = "xhttp"
-- TLS
if d.tls then
ds.security = "tls"
ds.tlsSettings = {}
if d.servername then
ds.tlsSettings.serverName = d.servername
end
if d["skip-cert-verify"] == true then
ds.tlsSettings.allowInsecure = true
end
if d["client-fingerprint"] then
ds.tlsSettings.fingerprint = d["client-fingerprint"]
end
if d.fingerprint then
ds.tlsSettings.pinnedPeerCertSha256 = d.fingerprint
end
if d.alpn then
ds.tlsSettings.alpn = d.alpn
end
end
-- xhttpSettings
local xs = {}
if d.path then xs.path = d.path end
if d.host then xs.host = d.host end
if next(xs) then
ds.xhttpSettings = xs
end
extra.downloadSettings = ds
end
if next(extra) then
o.transport.extra = json.stringify(extra)
end
end
end
return o
end
-- VLESS
local function encode_vless(node)
local o = build_common(node)
local link = "vless://" .. node.uuid .. "@" .. o.server .. ":" .. o.port
local p = {}
if node.flow then table.insert(p, "flow=" .. urlencode(node.flow)) end
if node.encryption then table.insert(p, "encryption=" .. urlencode(node.encryption)) end
-- TLS
if o.tls.security then table.insert(p, "security=" .. o.tls.security) end
if o.tls.pbk then table.insert(p, "pbk=" .. urlencode(o.tls.pbk)) end
if o.tls.sid then table.insert(p, "sid=" .. urlencode(o.tls.sid)) end
if o.tls.sni then table.insert(p, "sni=" .. urlencode(o.tls.sni)) end
if o.tls.fp then table.insert(p, "fp=" .. urlencode(o.tls.fp)) end
if o.tls.alpn then table.insert(p, "alpn=" .. urlencode(o.tls.alpn)) end
if o.tls.ech then table.insert(p, "ech=" .. urlencode(o.tls.ech)) end
if o.tls.pcs then table.insert(p, "pcs=" .. urlencode(o.tls.pcs)) end
table.insert(p, "allowInsecure=" .. (o.tls.insecure and "1" or "0"))
-- transport
table.insert(p, "type=" .. o.transport.type)
if o.transport.host then table.insert(p, "host=" .. urlencode(o.transport.host)) end
if o.transport.path then table.insert(p, "path=" .. urlencode(o.transport.path)) end
if o.transport.serviceName then table.insert(p, "serviceName=" .. urlencode(o.transport.serviceName)) end
if o.transport.mode then table.insert(p, "mode=" .. urlencode(o.transport.mode)) end
if o.transport.extra then table.insert(p, "extra=" .. urlencode(o.transport.extra)) end
if #p > 0 then
link = link .. "?" .. table.concat(p, "&")
end
return link .. "#" .. urlencode(o.name or "")
end
-- Trojan
local function encode_trojan(node)
local o = build_common(node)
local link = "trojan://" .. node.password .. "@" .. o.server .. ":" .. o.port
local p = {}
if o.tls.security then table.insert(p, "security=" .. o.tls.security) end
if o.tls.pbk then table.insert(p, "pbk=" .. urlencode(o.tls.pbk)) end
if o.tls.sid then table.insert(p, "sid=" .. urlencode(o.tls.sid)) end
if o.tls.sni then table.insert(p, "sni=" .. urlencode(o.tls.sni)) end
if o.tls.fp then table.insert(p, "fp=" .. urlencode(o.tls.fp)) end
if o.tls.alpn then table.insert(p, "alpn=" .. urlencode(o.tls.alpn)) end
if o.tls.ech then table.insert(p, "ech=" .. urlencode(o.tls.ech)) end
if o.tls.pcs then table.insert(p, "pcs=" .. urlencode(o.tls.pcs)) end
table.insert(p, "allowInsecure=" .. (o.tls.insecure and "1" or "0"))
table.insert(p, "type=" .. o.transport.type)
if o.transport.host then table.insert(p, "host=" .. urlencode(o.transport.host)) end
if o.transport.path then table.insert(p, "path=" .. urlencode(o.transport.path)) end
if o.transport.serviceName then table.insert(p, "serviceName=" .. urlencode(o.transport.serviceName)) end
if #p > 0 then
link = link .. "?" .. table.concat(p, "&")
end
return link .. "#" .. urlencode(o.name or "")
end
-- VMess
local function encode_vmess(node)
local o = build_common(node)
local obj = {
v = "2",
ps = o.name,
add = node.server,
port = tostring(node.port),
id = node.uuid,
aid = tostring(node.alterId or 0),
net = o.transport.type,
security = node.cipher,
scy = node.cipher,
type = "none",
host = o.transport.host or "",
path = o.transport.path or "",
tls = o.tls.security == "tls" and "tls" or "",
sni = o.tls.sni,
alpn = o.tls.alpn,
fp = o.tls.fp,
ech = o.tls.ech,
pcs = o.tls.pcs,
insecure = o.tls.insecure and "1" or "0",
tfo = node.tfo and "1" or "0"
}
if o.transport.type == "grpc" then
obj.path = o.transport.serviceName or ""
end
return "vmess://" .. base64(json.stringify(obj))
end
-- SS
local function encode_ss(node)
local userinfo = node.cipher .. ":" .. node.password
local base = userinfo .. "@" .. host_format(node.server) .. ":" .. node.port
local link = "ss://" .. base64(base)
local p = {}
if node.udp then table.insert(p, "udp=1") end
if node["udp-over-tcp"] then table.insert(p, "uot=1") end
if node.plugin then
local plugin = (node.plugin == "obfs") and "obfs-local" or node.plugin
if plugin == "shadow-tls" then
local shadow_tls = base64(json.stringify(node["plugin-opts"] or {}))
table.insert(p, "shadow-tls=" .. urlencode(shadow_tls))
else
local opts = {}
for k, v in pairs(node["plugin-opts"] or {}) do
if plugin == "obfs-local" then
if k == "mode" then k = "obfs" end
if k == "host" then k = "obfs-host" end
elseif plugin == "v2ray-plugin" then
if k == "mode" and v == "websocket" then
v = nil
elseif type(v) == "boolean" then
if v == true then
table.insert(opts, k)
end
v = nil
end
elseif plugin == "gost-plugin" then
if k == "mode" and v == "websocket" then v = "ws" end
if k == "host" then k = "serverName" end
if k == "headers" then v = nil end
end
if v ~= nil then
if type(v) == "boolean" then
v = v and "1" or "0"
end
table.insert(opts, k .. "=" .. v)
end
end
if #opts > 0 then plugin = plugin .. ";" .. table.concat(opts, ";") end
table.insert(p, "plugin=" .. urlencode(plugin))
end
end
if #p > 0 then
link = link .. "?" .. table.concat(p, "&")
end
return link .. "#" .. urlencode(node.name or "")
end
-- Hysteria
local function encode_hysteria2(node)
local link = "hysteria://" .. host_format(node.server) .. ":" .. (node.port or "")
local p = {}
if node["auth-str"] then table.insert(p, "auth=" .. node["auth-str"]) end
if node["ports"] then table.insert(p, "mport=" .. node["ports"]) end
if node.obfs then table.insert(p, "obfsParam=" .. node.obfs) end
if node.sni then table.insert(p, "sni=" .. node.sni) end
if node.up then table.insert(p, "upmbps=" .. node.up) end
if node.down then table.insert(p, "downmbps=" .. node.down) end
if node["skip-cert-verify"] then table.insert(p, "insecure=1") end
if node["fingerprint"] then table.insert(p, "pinSHA256=" .. urlencode(node["fingerprint"])) end
if node.alpn then
table.insert(p, "alpn=" .. urlencode(build_alpn(node.alpn)))
end
if #p > 0 then
link = link .. "?" .. table.concat(p, "&")
end
return link .. "#" .. urlencode(node.name or "")
end
-- Hysteria2
local function encode_hysteria2(node)
local link = "hysteria2://" .. (node.password or "") .. "@" .. host_format(node.server) .. ":" .. (node.port or "")
local p = {}
if node["ports"] then table.insert(p, "mport=" .. urlencode(node["ports"])) end
if node.obfs then table.insert(p, "obfs=" .. node.obfs) end
if node["obfs-password"] then table.insert(p, "obfs-password=" .. node["obfs-password"]) end
if node.up then table.insert(p, "upmbps=" .. node.up) end
if node.down then table.insert(p, "downmbps=" .. node.down) end
if node.sni then table.insert(p, "sni=" .. urlencode(node.sni)) end
if node["skip-cert-verify"] then table.insert(p, "insecure=1") end
if node["fingerprint"] then table.insert(p, "pinSHA256=" .. urlencode(node["fingerprint"])) end
if #p > 0 then
link = link .. "?" .. table.concat(p, "&")
end
return link .. "#" .. urlencode(node.name or "")
end
-- TUIC
local function encode_tuic(node)
local link = "tuic://" .. node.uuid .. ":" .. node.password .. "@" .. host_format(node.server) .. ":" .. node.port
local p = {}
if node["congestion-controller"] then
table.insert(p, "congestion_control=" .. node["congestion-controller"])
end
if node.alpn then
table.insert(p, "alpn=" .. urlencode(build_alpn(node.alpn)))
end
if node.sni then table.insert(p, "sni=" .. urlencode(node.sni)) end
if node["disable-sni"] then table.insert(p, "disable_sni=1") end
if node["skip-cert-verify"] then table.insert(p, "allowInsecure=1") end
if node["udp-relay-mode"] then table.insert(p, "udp_relay_mode=" .. node["udp-relay-mode"]) end
if #p > 0 then
link = link .. "?" .. table.concat(p, "&")
end
return link .. "#" .. urlencode(node.name or "")
end
-- AnyTLS
local function encode_anytls(node)
local o = build_common(node)
local link = "anytls://" .. (node.password or "") .. "@" .. host_format(node.server) .. ":" .. node.port
local p = {}
if o.tls.sni then table.insert(p, "sni=" .. urlencode(o.tls.sni)) end
if o.tls.alpn then table.insert(p, "alpn=" .. urlencode(o.tls.alpn)) end
if o.tls.fp then table.insert(p, "fp=" .. urlencode(o.tls.fp)) end
if o.tls.ech then table.insert(p, "ech=" .. urlencode(o.tls.ech)) end
if o.tls.pcs then
table.insert(p, "allowInsecure=1")
else
table.insert(p, "allowInsecure=" .. (o.tls.insecure and "1" or "0"))
end
if #p > 0 then
link = link .. "?" .. table.concat(p, "&")
end
return link .. "#" .. urlencode(node.name or "")
end
-- SSR
local function encode_ssr(node)
local link = host_format(node.server) .. ":" .. node.port .. ":" .. (node.protocol or "") .. ":" ..
(node.cipher or "") .. ":" .. (node.obfs or "") .. ":" .. base64(node.password)
local p = {}
if node["obfs-param"] then table.insert(p, "obfsparam=" .. base64(node["obfs-param"])) end
if node["protocol-param"] then table.insert(p, "protoparam=" .. base64(node["protocol-param"])) end
table.insert(p, "remarks=" .. base64(node.name))
if #p > 0 then
link = link .. "?" .. table.concat(p, "&")
end
return "ssr://" .. base64(link)
end
local function encode_node(node)
if (not node.type) or (not node.name) then return nil end
local t = node.type
if t == "vless" then return encode_vless(node)
elseif t == "trojan" then return encode_trojan(node)
elseif t == "vmess" then return encode_vmess(node)
elseif t == "ss" then return encode_ss(node)
elseif t == "hysteria" then return encode_hysteria(node)
elseif t == "hysteria2" then return encode_hysteria2(node)
elseif t == "tuic" then return encode_tuic(node)
elseif t == "anytls" then return encode_anytls(node)
elseif t == "ssr" then return encode_ssr(node)
else api.log("订阅转换 → 丢弃不支持的节点:" .. node.name .. ",节点类型:" .. t)
end
end
function parseClashNode(raw, remark)
if not raw then return "" end
local ok, lyaml = pcall(require, "lyaml")
if not ok then return raw end
local data = lyaml.load(raw)
if not data or type(data) ~= "table" then return raw end
if not data.proxies then return "" end
api.log('检测到 Clash 订阅,正在进行转换 ...')
local links = {}
for _, node in ipairs(data.proxies) do
local link = encode_node(node)
if link then
table.insert(links, link)
end
end
return #links > 0 and table.concat(links, "\n") or ""
end
function parse_clash_sub_info(headers)
local userinfo = headers:match("[Ss]ubscription%-userinfo:%s*([^\r\n]+)")
if not userinfo then return nil end
local upload = tonumber(userinfo:match("upload=(%d+)")) or 0
local download = tonumber(userinfo:match("download=(%d+)")) or 0
local total = tonumber(userinfo:match("total=(%d+)")) or 0
local expire = tonumber(userinfo:match("expire=(%d+)"))
local remain = total - (upload + download)
if remain < 0 then remain = 0 end
local function format_size(bytes)
local units = { "B", "KB", "MB", "GB", "TB", "PB" }
local i = 1
while bytes >= 1024 and i < #units do
bytes = bytes / 1024
i = i + 1
end
if bytes >= 100 then
return string.format("%.0f%s", bytes, units[i])
elseif bytes >= 10 then
return string.format("%.1f%s", bytes, units[i])
else
return string.format("%.2f%s", bytes, units[i])
end
end
local rem_traffic = format_size(remain)
local expired_date
if expire and expire > 0 then
local t = os.date("*t", expire)
expired_date = string.format("%d-%d-%d", t.year, t.month, t.day)
else
expired_date = "长期有效"
end
return {
rem_traffic = rem_traffic,
expired_date = expired_date
}
end
@@ -0,0 +1,238 @@
#!/usr/bin/lua
local api = require ("luci.passwall.api")
local appname = api.appname
local fs = api.fs
local jsonc = api.jsonc
local uci = api.uci
local sys = api.sys
local log = function(...)
api.log(...)
end
function get_ip_port_from(str)
local result_port = sys.exec("echo -n " .. str .. " | sed -n 's/^.*[:#]\\([0-9]*\\)$/\\1/p'")
local result_ip = sys.exec(string.format("__host=%s;__varport=%s;", str, result_port) .. "echo -n ${__host%%${__varport:+[:#]${__varport}*}}")
return result_ip, result_port
end
local var = api.get_args(arg)
local haproxy_path = var["-path"]
local haproxy_conf = var["-conf"]
local haproxy_dns = var["-dns"] or "119.29.29.29:53,223.5.5.5:53"
local cpu_thread = sys.exec('echo -n $(cat /proc/cpuinfo | grep "processor" | wc -l)') or "1"
local health_check_type = uci:get(appname, "@global_haproxy[0]", "health_check_type") or "tcp"
local health_check_inter = uci:get(appname, "@global_haproxy[0]", "health_check_inter") or "20"
local balancingStrategy = uci:get(appname, "@global_haproxy[0]", "balancingStrategy") or "roundrobin"
local console_port = uci:get(appname, "@global_haproxy[0]", "console_port")
local bind_local = uci:get(appname, "@global_haproxy[0]", "bind_local") or "0"
local bind_address = "0.0.0.0"
if bind_local == "1" then bind_address = "127.0.0.1" end
log("HAPROXY 负载均衡:")
log(string.format(" * 控制台端口:%s", console_port))
fs.mkdir(haproxy_path)
local haproxy_file = haproxy_path .. "/" .. haproxy_conf
local f_out = io.open(haproxy_file, "a")
local haproxy_config = [[
global
daemon
log 127.0.0.1 local2
maxconn 60000
stats socket {{path}}/haproxy.sock
nbthread {{nbthread}}
external-check
insecure-fork-wanted
spread-checks 50
tune.max-checks-per-thread 5
defaults
mode tcp
log global
option tcplog
option dontlognull
option http-server-close
#option forwardfor except 127.0.0.0/8
option redispatch
retries 2
timeout http-request 10s
timeout queue 1m
timeout connect 10s
timeout client 1m
timeout server 1m
timeout http-keep-alive 10s
timeout check 10s
maxconn 3000
resolvers mydns
resolve_retries 1
timeout resolve 5s
hold valid 600s
{{dns}}
]]
haproxy_config = haproxy_config:gsub("{{path}}", haproxy_path)
haproxy_config = haproxy_config:gsub("{{nbthread}}", cpu_thread)
local mydns = ""
local index = 0
string.gsub(haproxy_dns, '[^' .. "," .. ']+', function(w)
index = index + 1
local s = w:gsub("#", ":")
if not s:find(":") then
s = s .. ":53"
end
mydns = mydns .. (index > 1 and "\n" or "") .. " " .. string.format("nameserver dns%s %s", index, s)
end)
haproxy_config = haproxy_config:gsub("{{dns}}", mydns)
f_out:write(haproxy_config)
local listens = {}
uci:foreach(appname, "haproxy_config", function(t)
if t.enabled == "1" then
local server_remark
local server_address
local server_port
local lbss = t.lbss
local listen_port = tonumber(t.haproxy_port) or 0
local server_node = uci:get_all(appname, lbss)
local hop = (health_check_type == "passwall_logic") and (server_node.hysteria_hop or server_node.hysteria2_hop) or nil
hop = hop and hop:gsub(":", "-") or nil
if server_node and server_node.address and (server_node.port or hop) then
server_remark = server_node.address .. ":" .. (server_node.port or hop)
server_address = server_node.address
server_port = server_node.port or hop
t.origin_address = server_address
t.origin_port = server_port
if health_check_type == "passwall_logic" then
if server_node.type ~= "Socks" then
local relay_port = server_node.port
local new_port = api.get_new_port()
local config_file = string.format("haproxy_%s_%s.json", t[".name"], new_port)
sys.call(string.format('/usr/share/%s/app.sh run_socks "%s"> /dev/null',
appname,
string.format("flag=%s node=%s bind=%s socks_port=%s config_file=%s",
new_port, --flag
server_node[".name"], --node
"127.0.0.1", --bind
new_port, --socks port
config_file --config file
)
)
)
server_address = "127.0.0.1"
server_port = new_port
end
end
else
server_address, server_port = get_ip_port_from(lbss)
server_remark = server_address .. ":" .. server_port
t.origin_address = server_address
t.origin_port = server_port
end
if server_address and server_port and listen_port > 0 then
if not listens[listen_port] then
listens[listen_port] = {}
end
t.server_remark = server_remark
t.server_address = server_address
t.server_port = server_port
table.insert(listens[listen_port], t)
else
log(" - 丢弃1个明显无效的节点")
end
end
end)
local inter = tonumber((health_check_inter or "20"):match("^%d+"))
inter = (inter and inter >= 20) and inter or 20
local inter_arg = "inter " .. inter .. "s fastinter " .. inter / 2 .. "s downinter " .. inter * 1.5 .. "s"
local sortTable = {}
for i in pairs(listens) do
if i ~= nil then
table.insert(sortTable, i)
end
end
table.sort(sortTable, function(a,b) return (a < b) end)
for i, port in pairs(sortTable) do
log(" + 入口 %s:%s" % {bind_address, port})
f_out:write("\n" .. string.format([[
listen %s
bind %s:%s
mode tcp
balance %s
]], port, bind_address, port, balancingStrategy))
if health_check_type == "passwall_logic" then
f_out:write(string.format([[
option external-check
external-check command "/usr/share/%s/haproxy_check.sh"
]], appname))
end
local count_M, count_B = 1, 1
for i, o in ipairs(listens[port]) do
local remark = o.server_remark or ""
-- 防止重名导致无法运行
if tostring(o.backup) ~= "1" then
remark = "M" .. count_M .. "-" .. remark
count_M = count_M + 1
else
remark = "B" .. count_B .. "-" .. remark
count_B = count_B + 1
end
local server = o.server_address .. ":" .. o.server_port
local server_conf = "server {{remark}} {{server}} weight {{weight}} {{resolvers}} check {{inter_arg}} rise 2 fall 2 {{backup}}"
server_conf = server_conf:gsub("{{remark}}", remark)
server_conf = server_conf:gsub("{{server}}", server)
server_conf = server_conf:gsub("{{weight}}", o.lbweight)
local resolvers = "resolvers mydns"
if api.is_ip(o.server_address) then
resolvers = ""
end
server_conf = server_conf:gsub("{{resolvers}}", resolvers)
server_conf = server_conf:gsub("{{inter_arg}}", inter_arg)
server_conf = server_conf:gsub("{{backup}}", tostring(o.backup) == "1" and "backup" or "")
f_out:write(" " .. api.trim(server_conf) .. "\n")
if o.export ~= "0" then
sys.call(string.format(". /usr/share/%s/utils.sh ; add_ip2route %s %s", appname, o.origin_address, o.export))
end
log(string.format(" | - 出口节点:%s:%s,权重:%s", o.origin_address, o.origin_port, o.lbweight))
end
end
--控制台配置
local console_user = uci:get(appname, "@global_haproxy[0]", "console_user")
local console_password = uci:get(appname, "@global_haproxy[0]", "console_password")
local str = [[
listen console
bind 0.0.0.0:%s
mode http
stats refresh 30s
stats uri /
stats admin if TRUE
%s
]]
f_out:write("\n" .. string.format(str, console_port, (console_user and console_user ~= "" and console_password and console_password ~= "") and "stats auth " .. console_user .. ":" .. console_password or ""))
f_out:close()
--passwall内置健康检查URL
if health_check_type == "passwall_logic" then
local probeUrl = uci:get(appname, "@global_haproxy[0]", "health_probe_url") or "https://www.google.com/generate_204"
local f_url = io.open(haproxy_path .. "/Probe_URL", "w")
f_url:write(probeUrl)
f_url:close()
end
+37
View File
@@ -0,0 +1,37 @@
#!/bin/sh
export PATH=/usr/sbin:/usr/bin:/sbin:/bin:/root/bin
CONFIG=passwall
listen_address=$1
listen_port=$2
server_address=$3
server_port=$4
busybox pgrep -af "${CONFIG}/" | grep -E 'app\.sh.*(start|stop)|nftables\.sh|iptables\.sh|subscribe\.lua' >/dev/null && {
# 特定任务执行中不检测
exit 0
}
probe_file="/tmp/etc/passwall/haproxy/Probe_URL"
probeUrl="https://www.google.com/generate_204"
if [ -f "$probe_file" ]; then
firstLine=$(head -n 1 "$probe_file" | tr -d ' \t\n')
[ -n "$firstLine" ] && probeUrl="$firstLine"
fi
extra_params="-x socks5h://${server_address}:${server_port}"
if /usr/bin/curl --help all | grep -q "\-\-retry-all-errors"; then
extra_params="${extra_params} --retry-all-errors"
fi
status=$(/usr/bin/curl -I -o /dev/null -skL ${extra_params} --connect-timeout 3 --retry 1 --max-time 6 -w "%{http_code}" "${probeUrl}")
case "$status" in
200|204)
exit 0
;;
*)
exit 1
;;
esac
@@ -0,0 +1,538 @@
local api = require "luci.passwall.api"
local appname = api.appname
local var = api.get_args(arg)
local FLAG = var["-FLAG"]
local LISTEN_PORT = var["-LISTEN_PORT"]
local DNS_LOCAL = var["-DNS_LOCAL"]
local DNS_TRUST = var["-DNS_TRUST"]
local USE_DIRECT_LIST = var["-USE_DIRECT_LIST"]
local USE_PROXY_LIST = var["-USE_PROXY_LIST"]
local USE_BLOCK_LIST = var["-USE_BLOCK_LIST"]
local GFWLIST = var["-GFWLIST"]
local CHNLIST = var["-CHNLIST"]
local NO_IPV6_TRUST = var["-NO_IPV6_TRUST"]
local DEFAULT_MODE = var["-DEFAULT_MODE"]
local DEFAULT_TAG = var["-DEFAULT_TAG"]
local NO_LOGIC_LOG = var["-NO_LOGIC_LOG"]
local TCP_NODE = var["-TCP_NODE"]
local NFTFLAG = var["-NFTFLAG"]
local REMOTE_FAKEDNS = var["-REMOTE_FAKEDNS"]
local FILTER_HTTPS = var["-FILTER_HTTPS"]
local LOG_FILE = var["-LOG_FILE"]
local uci = api.uci
local sys = api.sys
local fs = api.fs
local datatypes = api.datatypes
local TMP_PATH = "/tmp/etc/" .. appname
local TMP_ACL_PATH = TMP_PATH .. "/acl"
local RULES_PATH = "/usr/share/" .. appname .. "/rules"
local FLAG_PATH = TMP_ACL_PATH .. "/" .. FLAG
local config_lines = {}
local tmp_lines = {}
local USE_GEOVIEW = uci:get(appname, "@global_rules[0]", "enable_geoview")
local IS_SHUNT_NODE = uci:get(appname, TCP_NODE, "protocol") == "_shunt"
if IS_SHUNT_NODE then
REMOTE_FAKEDNS = uci:get(appname, TCP_NODE, "fakedns") or "0"
end
local function log(...)
if NO_LOGIC_LOG == "1" then
return
end
api.log(...)
end
local function is_file_nonzero(path)
if path and #path > 1 then
if sys.exec('[ -s "%s" ] && echo -n 1' % path) == "1" then
return true
end
end
return nil
end
local function insert_unique(dest_table, value, lookup_table)
if not lookup_table[value] then
table.insert(dest_table, value)
lookup_table[value] = true
end
end
local function merge_array(array1, array2)
for i, line in ipairs(array2) do
table.insert(array1, #array1 + 1, line)
end
end
local function insert_array_before(array1, array2, target) --将array2插入到array1的target前面,target不存在则追加
for i, line in ipairs(array1) do
if line == target then
for j = #array2, 1, -1 do
table.insert(array1, i, array2[j])
end
return
end
end
merge_array(array1, array2)
end
local function insert_array_after(array1, array2, target) --将array2插入到array1的target后面,target不存在则追加
for i, line in ipairs(array1) do
if line == target then
for j = 1, #array2 do
table.insert(array1, i + j, array2[j])
end
return
end
end
merge_array(array1, array2)
end
local function get_geosite(list_arg, out_path)
local geosite_path = uci:get(appname, "@global_rules[0]", "v2ray_location_asset") or "/usr/share/v2ray/"
geosite_path = geosite_path:match("^(.*)/") .. "/geosite.dat"
if not is_file_nonzero(geosite_path) then return 1 end
local bin = api.finded_com("geoview")
if bin and list_arg and out_path then
local cmd = string.format("%q -type geosite -append=true -input %q -list %q -output %q -lowmem=true",
bin, geosite_path, list_arg, out_path)
sys.call(cmd)
return 0
end
return 1
end
if not fs.access(FLAG_PATH) then
fs.mkdir(FLAG_PATH)
end
local setflag = (NFTFLAG == "1") and "inet@passwall@" or ""
local only_global = (DEFAULT_MODE == "proxy" and CHNLIST == "0" and GFWLIST == "0") and 1
config_lines = {
LOG_FILE ~= "/dev/null" and "verbose" or "",
"bind-addr ::",
"bind-port " .. LISTEN_PORT,
"china-dns " .. DNS_LOCAL,
"trust-dns " .. DNS_TRUST,
tonumber(FILTER_HTTPS) == 1 and "filter-qtype 65" or ""
}
for i = 1, 6 do
table.insert(config_lines, "#--" .. i)
end
--自定义规则组,后声明的组具有更高优先级
--屏蔽列表
local file_block_host = TMP_ACL_PATH .. "/block_host"
if USE_BLOCK_LIST == "1" and not fs.access(file_block_host) then
local block_domain, lookup_block_domain = {}, {}
local geosite_arg = ""
for line in io.lines(RULES_PATH .. "/block_host") do
if not line:find("#") and line:find("geosite:") then
line = string.match(line, ":([^:]+)$")
geosite_arg = geosite_arg .. (geosite_arg ~= "" and "," or "") .. line
else
line = api.get_std_domain(line)
if line ~= "" and not line:find("#") then
insert_unique(block_domain, line, lookup_block_domain)
end
end
end
if #block_domain > 0 then
local f_out = io.open(file_block_host, "w")
for i = 1, #block_domain do
f_out:write(block_domain[i] .. "\n")
end
f_out:close()
end
if USE_GEOVIEW == "1" and geosite_arg ~= "" and api.is_finded("geoview") then
if get_geosite(geosite_arg, file_block_host) == 0 then
log(" * 解析[屏蔽列表] Geosite 到屏蔽域名表(blocklist)完成")
else
log(" * 解析[屏蔽列表] Geosite 到屏蔽域名表(blocklist)失败!")
end
end
end
if USE_BLOCK_LIST == "1" and is_file_nonzero(file_block_host) then
tmp_lines = {
"group null",
"group-dnl " .. file_block_host
}
insert_array_after(config_lines, tmp_lines, "#--5")
end
--始终用国内DNS解析节点域名
local file_vpslist = TMP_ACL_PATH .. "/vpslist"
if not is_file_nonzero(file_vpslist) then
local f_out = io.open(file_vpslist, "w")
local written_domains = {}
local function process_address(address)
address = (address or ""):lower()
if api.vps_domain_exclude(address) then return end
if datatypes.hostname(address) and not written_domains[address] then
f_out:write(address .. "\n")
written_domains[address] = true
end
end
uci:foreach(appname, "nodes", function(t)
process_address(t.address)
process_address(t.download_address)
end)
uci:foreach(appname, "subscribe_list", function(t) --订阅链接
local url, _ = api.get_domain_port_from_url(t.url or "")
if url and url ~= "" then
process_address(url)
end
end)
f_out:close()
end
if is_file_nonzero(file_vpslist) then
local sets = {
setflag .. "psw_vps",
setflag .. "psw_vps6"
}
tmp_lines = {
"group vpslist",
"group-dnl " .. file_vpslist,
"group-upstream " .. DNS_LOCAL,
"group-ipset " .. table.concat(sets, ",")
}
insert_array_after(config_lines, tmp_lines, "#--6")
log(string.format(" - 节点列表中的域名(vpslist):%s", DNS_LOCAL or "默认"))
end
--直连(白名单)列表
local file_direct_host = TMP_ACL_PATH .. "/direct_host"
if USE_DIRECT_LIST == "1" and not fs.access(file_direct_host) then
local direct_domain, lookup_direct_domain = {}, {}
local geosite_arg = ""
for line in io.lines(RULES_PATH .. "/direct_host") do
if not line:find("#") and line:find("geosite:") then
line = string.match(line, ":([^:]+)$")
geosite_arg = geosite_arg .. (geosite_arg ~= "" and "," or "") .. line
else
line = api.get_std_domain(line)
if line ~= "" and not line:find("#") then
insert_unique(direct_domain, line, lookup_direct_domain)
end
end
end
if #direct_domain > 0 then
local f_out = io.open(file_direct_host, "w")
for i = 1, #direct_domain do
f_out:write(direct_domain[i] .. "\n")
end
f_out:close()
end
if USE_GEOVIEW == "1" and geosite_arg ~= "" and api.is_finded("geoview") then
if get_geosite(geosite_arg, file_direct_host) == 0 then
log(" * 解析[直连列表] Geosite 到域名白名单(whitelist)完成")
else
log(" * 解析[直连列表] Geosite 到域名白名单(whitelist)失败!")
end
end
end
if USE_DIRECT_LIST == "1" and is_file_nonzero(file_direct_host) then
local sets = {
setflag .. "psw_white",
setflag .. "psw_white6"
}
tmp_lines = {
"group directlist",
"group-dnl " .. file_direct_host,
"group-upstream " .. DNS_LOCAL,
"group-ipset " .. table.concat(sets, ",")
}
insert_array_after(config_lines, tmp_lines, "#--4")
log(string.format(" - 域名白名单(whitelist):%s", DNS_LOCAL or "默认"))
end
--代理(黑名单)列表
local file_proxy_host = TMP_ACL_PATH .. "/proxy_host"
if USE_PROXY_LIST == "1" and not fs.access(file_proxy_host) then
local proxy_domain, lookup_proxy_domain = {}, {}
local geosite_arg = ""
for line in io.lines(RULES_PATH .. "/proxy_host") do
if not line:find("#") and line:find("geosite:") then
line = string.match(line, ":([^:]+)$")
geosite_arg = geosite_arg .. (geosite_arg ~= "" and "," or "") .. line
else
line = api.get_std_domain(line)
if line ~= "" and not line:find("#") then
insert_unique(proxy_domain, line, lookup_proxy_domain)
end
end
end
if #proxy_domain > 0 then
local f_out = io.open(file_proxy_host, "w")
for i = 1, #proxy_domain do
f_out:write(proxy_domain[i] .. "\n")
end
f_out:close()
end
if USE_GEOVIEW == "1" and geosite_arg ~= "" and api.is_finded("geoview") then
if get_geosite(geosite_arg, file_proxy_host) == 0 then
log(" * 解析[代理列表] Geosite 到代理域名表(blacklist)完成")
else
log(" * 解析[代理列表] Geosite 到代理域名表(blacklist)失败!")
end
end
end
if USE_PROXY_LIST == "1" and is_file_nonzero(file_proxy_host) then
local sets = {
setflag .. "psw_black",
setflag .. "psw_black6"
}
if FLAG ~= "default" then
sets = {
setflag .. "psw_" .. FLAG .. "_black",
setflag .. "psw_" .. FLAG .. "_black6"
}
end
tmp_lines = {
"group proxylist",
"group-dnl " .. file_proxy_host,
"group-upstream " .. DNS_TRUST,
REMOTE_FAKEDNS ~= "1" and "group-ipset " .. table.concat(sets, ",") or ""
}
if NO_IPV6_TRUST == "1" then table.insert(tmp_lines, "no-ipv6 tag:proxylist") end
insert_array_after(config_lines, tmp_lines, "#--3")
log(string.format(" - 代理域名表(blacklist):%s", DNS_TRUST or "默认"))
end
--内置组(chn/gfw)优先级在自定义组后
--GFW列表
if GFWLIST == "1" and is_file_nonzero(RULES_PATH .. "/gfwlist") then
local sets = {
setflag .. "psw_gfw",
setflag .. "psw_gfw6"
}
if FLAG ~= "default" then
sets = {
setflag .. "psw_" .. FLAG .. "_gfw",
setflag .. "psw_" .. FLAG .. "_gfw6"
}
end
tmp_lines = {
"gfwlist-file " .. RULES_PATH .. "/gfwlist",
REMOTE_FAKEDNS ~= "1" and "add-taggfw-ip " .. table.concat(sets, ",") or ""
}
if NO_IPV6_TRUST == "1" then table.insert(tmp_lines, "no-ipv6 tag:gfw") end
merge_array(config_lines, tmp_lines)
log(string.format(" - 防火墙域名表(gfwlist):%s", DNS_TRUST or "默认"))
end
--中国列表
if CHNLIST ~= "0" and is_file_nonzero(RULES_PATH .. "/chnlist") then
if CHNLIST == "direct" then
tmp_lines = {
"chnlist-file " .. RULES_PATH .. "/chnlist",
"ipset-name4 " .. setflag .. "psw_chn",
"ipset-name6 " .. setflag .. "psw_chn6",
"add-tagchn-ip",
"chnlist-first"
}
merge_array(config_lines, tmp_lines)
log(string.format(" - 中国域名表(chnroute):%s", DNS_LOCAL or "默认"))
end
--回中国模式
if CHNLIST == "proxy" then
local sets = {
setflag .. "psw_chn",
setflag .. "psw_chn6"
}
tmp_lines = {
"group chn_proxy",
"group-dnl " .. RULES_PATH .. "/chnlist",
"group-upstream " .. DNS_TRUST,
REMOTE_FAKEDNS ~= "1" and "group-ipset " .. table.concat(sets, ",") or ""
}
if NO_IPV6_TRUST == "1" then table.insert(tmp_lines, "no-ipv6 tag:chn_proxy") end
insert_array_after(config_lines, tmp_lines, "#--1")
log(string.format(" - 中国域名表(chnroute):%s", DNS_TRUST or "默认"))
end
end
--分流规则
if IS_SHUNT_NODE then
local white_domain, lookup_white_domain = {}, {}
local shunt_domain, lookup_shunt_domain = {}, {}
local file_white_host = FLAG_PATH .. "/shunt_direct_host"
local file_shunt_host = FLAG_PATH .. "/shunt_proxy_host"
local geosite_white_arg, geosite_shunt_arg = "", ""
local t = uci:get_all(appname, TCP_NODE)
local default_node_id = t["default_node"] or "_direct"
uci:foreach(appname, "shunt_rules", function(s)
local _node_id = t[s[".name"]]
if _node_id and _node_id ~= "_blackhole" then
if _node_id == "_default" then
_node_id = default_node_id
end
local domain_list = s.domain_list or ""
for line in string.gmatch(domain_list, "[^\r\n]+") do
if line ~= "" and not line:find("#") and not line:find("regexp:") and not line:find("ext:") then
if line:find("geosite:") then
line = string.match(line, ":([^:]+)$")
if _node_id == "_direct" then
geosite_white_arg = geosite_white_arg .. (geosite_white_arg ~= "" and "," or "") .. line
else
geosite_shunt_arg = geosite_shunt_arg .. (geosite_shunt_arg ~= "" and "," or "") .. line
end
else
if line:find("domain:") or line:find("full:") then
line = string.match(line, ":([^:]+)$")
end
line = api.get_std_domain(line)
if line ~= "" and not line:find("#") then
if _node_id == "_direct" then
insert_unique(white_domain, line, lookup_white_domain)
else
insert_unique(shunt_domain, line, lookup_shunt_domain)
end
end
end
end
end
if _node_id ~= "_direct" then
log(string.format(" - Sing-Box/Xray分流规则(%s):%s", s.remarks, DNS_TRUST or "默认"))
end
end
end)
if is_file_nonzero(file_white_host) == nil then
if #white_domain > 0 then
local f_out = io.open(file_white_host, "w")
for i = 1, #white_domain do
f_out:write(white_domain[i] .. "\n")
end
f_out:close()
end
end
if is_file_nonzero(file_shunt_host) == nil then
if #shunt_domain > 0 then
local f_out = io.open(file_shunt_host, "w")
for i = 1, #shunt_domain do
f_out:write(shunt_domain[i] .. "\n")
end
f_out:close()
end
end
if GFWLIST == "1" and CHNLIST == "0" and USE_GEOVIEW == "1" and api.is_finded("geoview") then --仅GFW模式解析geosite
local return_white, return_shunt
if geosite_white_arg ~= "" then
return_white = get_geosite(geosite_white_arg, file_white_host)
end
if geosite_shunt_arg ~= "" then
return_shunt = get_geosite(geosite_shunt_arg, file_shunt_host)
end
if (return_white == nil or return_white == 0) and (return_shunt == nil or return_shunt == 0) then
log(" * 解析[分流节点] Geosite 完成")
else
log(" * 解析[分流节点] Geosite 失败!")
end
end
local sets = {
setflag .. "psw_shunt",
setflag .. "psw_shunt6"
}
if FLAG ~= "default" then
sets = {
setflag .. "psw_" .. FLAG .. "_shunt",
setflag .. "psw_" .. FLAG .. "_shunt6"
}
end
if is_file_nonzero(file_white_host) then
if USE_DIRECT_LIST == "1" then
--当白名单启用时,添加到白名单组一同处理
for i, v in ipairs(config_lines) do
if v == "group-dnl " .. file_direct_host then
config_lines[i] = "group-dnl " .. file_direct_host .. "," .. file_white_host
break
end
end
else
--当白名单不启用时,创建新组,ipset到shuntlist
tmp_lines = {
"group whitelist",
"group-dnl " .. file_white_host,
"group-upstream " .. DNS_LOCAL,
"group-ipset " .. table.concat(sets, ",")
}
insert_array_after(config_lines, tmp_lines, "#--4")
end
end
if is_file_nonzero(file_shunt_host) then
tmp_lines = {
"group shuntlist",
"group-dnl " .. file_shunt_host,
"group-upstream " .. DNS_TRUST,
(not only_global and REMOTE_FAKEDNS == "1") and "" or ("group-ipset " .. table.concat(sets, ","))
}
if NO_IPV6_TRUST == "1" then table.insert(tmp_lines, "no-ipv6 tag:shuntlist") end
insert_array_after(config_lines, tmp_lines, "#--2")
end
end
--只使用gfwlist模式,GFW列表以外的域名及默认使用本地DNS
if GFWLIST == "1" and CHNLIST == "0" then DEFAULT_TAG = "chn" end
--回中国模式,中国列表以外的域名及默认使用本地DNS
if CHNLIST == "proxy" then DEFAULT_TAG = "chn" end
--全局模式,默认使用远程DNS
if only_global then
DEFAULT_TAG = "gfw"
if NO_IPV6_TRUST == "1" and not IS_SHUNT_NODE then
table.insert(config_lines, "no-ipv6")
end
end
--是否接受直连 DNS 空响应
if DEFAULT_TAG == "none_noip" then table.insert(config_lines, "noip-as-chnip") end
if DEFAULT_TAG == nil or DEFAULT_TAG == "smart" or DEFAULT_TAG == "none_noip" then DEFAULT_TAG = "none" end
table.insert(config_lines, "default-tag " .. DEFAULT_TAG)
if DEFAULT_TAG == "none" then
table.insert(config_lines, "verdict-cache 5000")
end
table.insert(config_lines, "hosts")
if DEFAULT_TAG == "chn" then
log(string.format(" - 默认 DNS :%s", DNS_LOCAL))
elseif DEFAULT_TAG == "gfw" then
log(string.format(" - 默认 DNS :%s", DNS_TRUST))
else
log(string.format(" - 默认 DNS :%s", "智能匹配"))
end
--输出配置文件
if #config_lines > 0 then
for i = 1, #config_lines do
line = config_lines[i]
if line ~= "" and not line:find("^#--") then
print(line)
end
end
end
log(" - ChinaDNS-NG已作为Dnsmasq上游,如果你自行配置了错误的DNS流程,将会导致域名(直连/代理域名)分流失效!!!")
@@ -0,0 +1,737 @@
local api = require "luci.passwall.api"
local appname = "passwall"
local uci = api.uci
local sys = api.sys
local fs = api.fs
local datatypes = api.datatypes
local TMP = {}
local function tinsert(table_name, val)
if table_name and type(table_name) == "table" then
if not TMP[table_name] then
TMP[table_name] = {}
end
if TMP[table_name][val] then
return false
end
table.insert(table_name, val)
TMP[table_name][val] = true
return true
end
return false
end
local function backup_servers()
local DNSMASQ_DNS = uci:get("dhcp", "@dnsmasq[0]", "server")
if DNSMASQ_DNS and #DNSMASQ_DNS > 0 then
uci:set(appname, "@global[0]", "dnsmasq_servers", DNSMASQ_DNS)
api.uci_save(uci, appname, true)
end
end
local function restore_servers()
local dns_table = {}
local DNSMASQ_DNS = uci:get("dhcp", "@dnsmasq[0]", "server")
if DNSMASQ_DNS and #DNSMASQ_DNS > 0 then
for k, v in ipairs(DNSMASQ_DNS) do
tinsert(dns_table, v)
end
end
local OLD_SERVER = uci:get(appname, "@global[0]", "dnsmasq_servers")
if OLD_SERVER and #OLD_SERVER > 0 then
for k, v in ipairs(OLD_SERVER) do
tinsert(dns_table, v)
end
uci:delete(appname, "@global[0]", "dnsmasq_servers")
api.uci_save(uci, appname, true)
end
if dns_table and #dns_table > 0 then
uci:set_list("dhcp", "@dnsmasq[0]", "server", dns_table)
api.uci_save(uci, "dhcp", true)
end
end
function stretch()
local dnsmasq_server = uci:get("dhcp", "@dnsmasq[0]", "server")
local dnsmasq_noresolv = uci:get("dhcp", "@dnsmasq[0]", "noresolv")
local _flag
if dnsmasq_server and #dnsmasq_server > 0 then
for k, v in ipairs(dnsmasq_server) do
if not v:find("/") then
_flag = true
end
end
end
if not _flag and dnsmasq_noresolv == "1" then
uci:delete("dhcp", "@dnsmasq[0]", "noresolv")
local RESOLVFILE = "/tmp/resolv.conf.d/resolv.conf.auto"
local file = io.open(RESOLVFILE, "r")
if not file then
RESOLVFILE = "/tmp/resolv.conf.auto"
else
local size = file:seek("end")
file:close()
if size == 0 then
RESOLVFILE = "/tmp/resolv.conf.auto"
end
end
uci:set("dhcp", "@dnsmasq[0]", "resolvfile", RESOLVFILE)
api.uci_save(uci, "dhcp", true)
end
end
function restart(var)
local LOG = var["-LOG"]
sys.call("/etc/init.d/dnsmasq restart >/dev/null 2>&1")
if LOG == "1" then
api.log("重启 dnsmasq 服务")
end
end
function logic_restart(var)
local LOG = var["-LOG"]
local DEFAULT_DNS = api.get_cache_var("DEFAULT_DNS")
if DEFAULT_DNS then
backup_servers()
--sys.call("sed -i '/list server/d' /etc/config/dhcp >/dev/null 2>&1")
local dns_table = {}
local dnsmasq_server = uci:get("dhcp", "@dnsmasq[0]", "server")
if dnsmasq_server and #dnsmasq_server > 0 then
for k, v in ipairs(dnsmasq_server) do
if v:find("/") then
tinsert(dns_table, v)
end
end
uci:set_list("dhcp", "@dnsmasq[0]", "server", dns_table)
api.uci_save(uci, "dhcp", true)
end
sys.call("/etc/init.d/dnsmasq restart >/dev/null 2>&1")
restore_servers()
else
sys.call("/etc/init.d/dnsmasq restart >/dev/null 2>&1")
end
if LOG == "1" then
api.log("重启 dnsmasq 服务")
end
end
function copy_instance(var)
local LISTEN_PORT = var["-LISTEN_PORT"]
local TMP_DNSMASQ_PATH = var["-TMP_DNSMASQ_PATH"]
local conf_lines = {}
local DEFAULT_DNSMASQ_CFGID = sys.exec("echo -n $(uci -q show dhcp.@dnsmasq[0] | awk 'NR==1 {split($0, conf, /[.=]/); print conf[2]}')")
for line in io.lines("/tmp/etc/dnsmasq.conf." .. DEFAULT_DNSMASQ_CFGID) do
local filter
if line:find("passwall") then filter = true end
if line:find("ubus") then filter = true end
if line:find("dhcp") then filter = true end
if line:find("server=") == 1 then filter = true end
if line:find("port=") == 1 then filter = true end
if line:find("conf%-dir=") == 1 then
filter = true
if TMP_DNSMASQ_PATH then
local tmp_path = line:sub(1 + #"conf-dir=")
sys.call(string.format("cp -r %s/* %s/ 2>/dev/null", tmp_path, TMP_DNSMASQ_PATH))
end
end
if line:find("address=") == 1 or (line:find("server=") == 1 and line:find("/")) then filter = nil end
if not filter then
tinsert(conf_lines, line)
end
end
tinsert(conf_lines, "port=" .. LISTEN_PORT)
if TMP_DNSMASQ_PATH then
sys.call("rm -rf " .. TMP_DNSMASQ_PATH .. "/*passwall*")
end
if var["-return"] == "1" then
return conf_lines
end
if #conf_lines > 0 then
local DNSMASQ_CONF = var["-DNSMASQ_CONF"]
local conf_out = io.open(DNSMASQ_CONF, "a")
conf_out:write(table.concat(conf_lines, "\n"))
conf_out:write("\n")
conf_out:close()
end
end
function add_rule(var)
local FLAG = var["-FLAG"]
local TMP_DNSMASQ_PATH = var["-TMP_DNSMASQ_PATH"]
local DNSMASQ_CONF_FILE = var["-DNSMASQ_CONF_FILE"]
local LISTEN_PORT = var["-LISTEN_PORT"]
local DEFAULT_DNS = var["-DEFAULT_DNS"]
local LOCAL_DNS = var["-LOCAL_DNS"]
local TUN_DNS = var["-TUN_DNS"]
local REMOTE_FAKEDNS = var["-REMOTE_FAKEDNS"]
local USE_DEFAULT_DNS = var["-USE_DEFAULT_DNS"]
local CHINADNS_DNS = var["-CHINADNS_DNS"]
local TCP_NODE = var["-TCP_NODE"]
local USE_DIRECT_LIST = var["-USE_DIRECT_LIST"]
local USE_PROXY_LIST = var["-USE_PROXY_LIST"]
local USE_BLOCK_LIST = var["-USE_BLOCK_LIST"]
local USE_GFW_LIST = var["-USE_GFW_LIST"]
local CHN_LIST = var["-CHN_LIST"]
local DEFAULT_PROXY_MODE = var["-DEFAULT_PROXY_MODE"]
local NO_PROXY_IPV6 = var["-NO_PROXY_IPV6"]
local NO_LOGIC_LOG = var["-NO_LOGIC_LOG"]
local NFTFLAG = var["-NFTFLAG"]
local CACHE_PATH = api.CACHE_PATH
local CACHE_FLAG = "dnsmasq_" .. FLAG
local CACHE_DNS_PATH = CACHE_PATH .. "/" .. CACHE_FLAG
local CACHE_TEXT_FILE = CACHE_DNS_PATH .. ".txt"
local USE_CHINADNS_NG = "0"
local IS_SHUNT_NODE = uci:get(appname, TCP_NODE, "protocol") == "_shunt"
if IS_SHUNT_NODE then
REMOTE_FAKEDNS = uci:get(appname, TCP_NODE, "fakedns") or "0"
end
local list1 = {}
local excluded_domain = {}
local function log(...)
if NO_LOGIC_LOG == "1" then
return
end
api.log(...)
end
local function check_dns(domain, dns)
if domain == "" or domain:find("#") then
return false
end
if not dns then
return
end
for k,v in ipairs(list1[domain].dns) do
if dns == v then
return true
end
end
return false
end
local function check_ipset(domain, ipset)
if domain == "" or domain:find("#") then
return false
end
if not ipset then
return
end
for k,v in ipairs(list1[domain].ipsets) do
if ipset == v then
return true
end
end
return false
end
local function set_domain_address(domain, address)
if domain == "" or domain:find("#") then
return
end
if not list1[domain] then
list1[domain] = {
dns = {},
ipsets = {}
}
end
if not list1[domain].address then
list1[domain].address = address
end
end
local function set_domain_dns(domain, dns)
if domain == "" or domain:find("#") then
return
end
if not dns then
return
end
if not list1[domain] then
list1[domain] = {
dns = {},
ipsets = {}
}
end
for line in string.gmatch(dns, '[^' .. "," .. ']+') do
if not check_dns(domain, line) then
table.insert(list1[domain].dns, line)
end
end
end
local function set_domain_ipset(domain, ipset)
if domain == "" or domain:find("#") then
return
end
if not ipset then
return
end
if not list1[domain] then
list1[domain] = {
dns = {},
ipsets = {}
}
end
for line in string.gmatch(ipset, '[^' .. "," .. ']+') do
if not check_ipset(domain, line) then
table.insert(list1[domain].ipsets, line)
end
end
end
local function add_excluded_domain(domain)
if domain == "" or domain:find("#") then
return
end
excluded_domain[domain] = true
end
local function check_excluded_domain(domain)
if domain == "" or domain:find("#") then
return false
end
if excluded_domain[domain] then
return true
end
local pos = domain:find(".", 1, true)
while pos do
if excluded_domain[domain:sub(pos + 1)] then
return true
end
pos = domain:find(".", pos + 1, true)
end
return false
end
local cache_text = ""
local nodes_address_md5 = sys.exec("echo -n $(uci show passwall | grep '\\.address') | md5sum")
local new_rules = sys.exec("echo -n $(find /usr/share/passwall/rules -type f | xargs md5sum)")
local new_text = TMP_DNSMASQ_PATH .. DNSMASQ_CONF_FILE .. DEFAULT_DNS .. LOCAL_DNS .. TUN_DNS .. REMOTE_FAKEDNS .. USE_DEFAULT_DNS .. CHINADNS_DNS .. USE_DIRECT_LIST .. USE_PROXY_LIST .. USE_BLOCK_LIST .. USE_GFW_LIST .. CHN_LIST .. DEFAULT_PROXY_MODE .. NO_PROXY_IPV6 .. nodes_address_md5 .. new_rules .. NFTFLAG
if fs.access(CACHE_TEXT_FILE) then
for line in io.lines(CACHE_TEXT_FILE) do
cache_text = line
end
end
if cache_text ~= new_text then
api.remove(CACHE_DNS_PATH .. "*")
end
local dnsmasq_default_dns
if USE_DEFAULT_DNS ~= "nil" then
if USE_DEFAULT_DNS == "direct" then
dnsmasq_default_dns = LOCAL_DNS
end
if USE_DEFAULT_DNS == "remote" then
dnsmasq_default_dns = TUN_DNS
end
if USE_DEFAULT_DNS == "remote" and CHN_LIST == "direct" then
dnsmasq_default_dns = TUN_DNS
end
end
local only_global
if DEFAULT_PROXY_MODE == "proxy" and CHN_LIST == "0" and USE_GFW_LIST == "0" then
--没有启用中国列表和GFW列表时
dnsmasq_default_dns = TUN_DNS
only_global = 1
end
if USE_DEFAULT_DNS == "chinadns_ng" and CHINADNS_DNS ~= "0" then
dnsmasq_default_dns = CHINADNS_DNS
USE_CHINADNS_NG = "1"
end
local setflag_4= (NFTFLAG == "1") and "4#inet#passwall#" or ""
local setflag_6= (NFTFLAG == "1") and "6#inet#passwall#" or ""
if not fs.access(CACHE_DNS_PATH) then
fs.mkdir(CACHE_DNS_PATH)
--屏蔽列表
if USE_CHINADNS_NG == "0" then
if USE_BLOCK_LIST == "1" then
for line in io.lines("/usr/share/passwall/rules/block_host") do
line = api.get_std_domain(line)
if line ~= "" and not line:find("#") and not line:find(":") then
set_domain_address(line, "")
end
end
end
end
local fwd_dns
local no_ipv6
--始终用国内DNS解析节点域名
if true then
fwd_dns = LOCAL_DNS
if USE_CHINADNS_NG == "1" then
fwd_dns = nil
else
local sets = {
setflag_4 .. "psw_vps",
setflag_6 .. "psw_vps6"
}
local function process_address(address)
address = (address or ""):lower()
if api.vps_domain_exclude(address) then return end
if datatypes.hostname(address) then
set_domain_dns(address, fwd_dns)
set_domain_ipset(address, table.concat(sets, ","))
end
end
uci:foreach(appname, "nodes", function(t)
process_address(t.address)
process_address(t.download_address)
end)
uci:foreach(appname, "subscribe_list", function(t) --订阅链接
local url, _ = api.get_domain_port_from_url(t.url or "")
if url and url ~= "" then
process_address(url)
end
end)
log(string.format(" - 节点列表中的域名(vpslist):%s", fwd_dns or "默认"))
end
end
--直连(白名单)列表
if USE_DIRECT_LIST == "1" then
if fs.access("/usr/share/passwall/rules/direct_host") then
fwd_dns = LOCAL_DNS
if USE_CHINADNS_NG == "1" then
fwd_dns = nil
end
if fwd_dns then
local sets = {
setflag_4 .. "psw_white",
setflag_6 .. "psw_white6"
}
--始终用国内DNS解析直连(白名单)列表
for line in io.lines("/usr/share/passwall/rules/direct_host") do
line = api.get_std_domain(line)
if line ~= "" and not line:find("#") and not line:find(":") then
add_excluded_domain(line)
set_domain_dns(line, fwd_dns)
set_domain_ipset(line, table.concat(sets, ","))
end
end
log(string.format(" - 域名白名单(whitelist):%s", fwd_dns or "默认"))
end
end
end
--代理(黑名单)列表
if USE_PROXY_LIST == "1" then
if fs.access("/usr/share/passwall/rules/proxy_host") then
fwd_dns = TUN_DNS
if USE_CHINADNS_NG == "1" then
fwd_dns = nil
end
if fwd_dns then
local set_name = "psw_black"
local set6_name = "psw_black6"
if FLAG ~= "default" then
set_name = "psw_" .. FLAG .. "_black"
set6_name = "psw_" .. FLAG .. "_black6"
end
local sets = {
setflag_4 .. set_name
}
if NO_PROXY_IPV6 ~= "1" then
table.insert(sets, setflag_6 .. set6_name)
end
if REMOTE_FAKEDNS == "1" then
sets = {}
end
--始终使用远程DNS解析代理(黑名单)列表
for line in io.lines("/usr/share/passwall/rules/proxy_host") do
line = api.get_std_domain(line)
if line ~= "" and not line:find("#") and not line:find(":") then
add_excluded_domain(line)
if NO_PROXY_IPV6 == "1" then
set_domain_address(line, "::")
end
set_domain_dns(line, fwd_dns)
set_domain_ipset(line, table.concat(sets, ","))
end
end
log(string.format(" - 代理域名表(blacklist):%s", fwd_dns or "默认"))
end
end
end
--GFW列表
if USE_GFW_LIST == "1" then
if fs.access("/usr/share/passwall/rules/gfwlist") then
fwd_dns = TUN_DNS
if USE_CHINADNS_NG == "1" then
fwd_dns = nil
end
if fwd_dns then
local set_name = "psw_gfw"
local set6_name = "psw_gfw6"
if FLAG ~= "default" then
set_name = "psw_" .. FLAG .. "_gfw"
set6_name = "psw_" .. FLAG .. "_gfw6"
end
local sets = {
setflag_4 .. set_name
}
if NO_PROXY_IPV6 ~= "1" then
table.insert(sets, setflag_6 .. set6_name)
end
if REMOTE_FAKEDNS == "1" then
sets = {}
end
for line in io.lines("/usr/share/passwall/rules/gfwlist") do
if line ~= "" and not line:find("#") and not check_excluded_domain(line) then
if NO_PROXY_IPV6 == "1" then
set_domain_address(line, "::")
end
if dnsmasq_default_dns == fwd_dns then
fwd_dns = nil
else
set_domain_dns(line, fwd_dns)
end
set_domain_ipset(line, table.concat(sets, ","))
end
end
log(string.format(" - 防火墙域名表(gfwlist):%s", fwd_dns or "默认"))
end
end
end
--中国列表
if CHN_LIST ~= "0" then
if fs.access("/usr/share/passwall/rules/chnlist") then
fwd_dns = nil
if CHN_LIST == "direct" then
fwd_dns = LOCAL_DNS
end
if CHN_LIST == "proxy" then
fwd_dns = TUN_DNS
end
if USE_CHINADNS_NG == "1" then
fwd_dns = nil
end
if fwd_dns then
local sets = {
setflag_4 .. "psw_chn",
setflag_6 .. "psw_chn6"
}
if CHN_LIST == "proxy" then
if NO_PROXY_IPV6 == "1" then
sets = {
setflag_4 .. "psw_chn"
}
end
if REMOTE_FAKEDNS == "1" then
sets = {}
end
end
for line in io.lines("/usr/share/passwall/rules/chnlist") do
if line ~= "" and not line:find("#") and not check_excluded_domain(line) then
if CHN_LIST == "proxy" and NO_PROXY_IPV6 == "1" then
set_domain_address(line, "::")
end
if dnsmasq_default_dns == fwd_dns then
fwd_dns = nil
else
set_domain_dns(line, fwd_dns)
end
set_domain_ipset(line, table.concat(sets, ","))
end
end
log(string.format(" - 中国域名表(chnroute):%s", fwd_dns or "默认"))
end
end
end
--分流规则
if IS_SHUNT_NODE and USE_CHINADNS_NG == "0" then
local t = uci:get_all(appname, TCP_NODE)
local default_node_id = t["default_node"] or "_direct"
uci:foreach(appname, "shunt_rules", function(s)
local _node_id = t[s[".name"]]
if _node_id and _node_id ~= "_blackhole" then
if _node_id == "_default" then
_node_id = default_node_id
end
fwd_dns = nil
no_ipv6 = nil
local sets = {}
if _node_id == "_direct" then
fwd_dns = LOCAL_DNS
if USE_DIRECT_LIST == "1" then
table.insert(sets, setflag_4 .. "psw_white")
table.insert(sets, setflag_6 .. "psw_white6")
else
local set_name = "psw_shunt"
local set6_name = "psw_shunt6"
if FLAG ~= "default" then
set_name = "psw_" .. FLAG .. "_shunt"
set6_name = "psw_" .. FLAG .. "_shunt6"
end
table.insert(sets, setflag_4 .. set_name)
table.insert(sets, setflag_6 .. set6_name)
end
else
local set_name = "psw_shunt"
local set6_name = "psw_shunt6"
if FLAG ~= "default" then
set_name = "psw_" .. FLAG .. "_shunt"
set6_name = "psw_" .. FLAG .. "_shunt6"
end
fwd_dns = TUN_DNS
table.insert(sets, setflag_4 .. set_name)
if NO_PROXY_IPV6 ~= "1" then
table.insert(sets, setflag_6 .. set6_name)
else
no_ipv6 = true
end
if not only_global then
if REMOTE_FAKEDNS == "1" then
sets = {}
end
end
end
local domain_list = s.domain_list or ""
for line in string.gmatch(domain_list, "[^\r\n]+") do
if line ~= "" and not line:find("#") and not line:find("regexp:") and not line:find("geosite:") and not line:find("ext:") then
if line:find("domain:") or line:find("full:") then
line = string.match(line, ":([^:]+)$")
end
line = api.get_std_domain(line)
add_excluded_domain(line)
if no_ipv6 then
set_domain_address(line, "::")
end
set_domain_dns(line, fwd_dns)
set_domain_ipset(line, table.concat(sets, ","))
end
end
if _node_id ~= "_direct" then
log(string.format(" - Sing-Box/Xray分流规则(%s):%s", s.remarks, fwd_dns or "默认"))
end
end
end)
elseif only_global == 1 and NO_PROXY_IPV6 == "1" then
--节点:固定节点
--代理模式:全局模式
--过滤代理域名 IPv6:启用
--禁止解析所有IPv6记录
list1["#"] = {
dns = {},
ipsets = {},
address = "::"
}
end
if list1 and next(list1) then
local address_out = io.open(CACHE_DNS_PATH .. "/000-address.conf", "a")
local server_out = io.open(CACHE_DNS_PATH .. "/001-server.conf", "a")
local ipset_out = io.open(CACHE_DNS_PATH .. "/ipset.conf", "a")
local set_name = "ipset"
if NFTFLAG == "1" then
set_name = "nftset"
end
for key, value in pairs(list1) do
if value.address then
local domain = "." .. key
if key == "#" then
domain = key
end
address_out:write(string.format("address=/%s/%s", domain, value.address) .. "\n")
end
if value.dns and #value.dns > 0 then
for i, dns in ipairs(value.dns) do
server_out:write(string.format("server=/.%s/%s", key, dns) .. "\n")
end
end
if value.ipsets and #value.ipsets > 0 then
local ipsets_str = ""
for i, ipset in ipairs(value.ipsets) do
ipsets_str = ipsets_str .. ipset .. ","
end
ipsets_str = ipsets_str:sub(1, #ipsets_str - 1)
ipset_out:write(string.format("%s=/.%s/%s", set_name, key, ipsets_str) .. "\n")
end
end
address_out:close()
server_out:close()
ipset_out:close()
end
local f_out = io.open(CACHE_TEXT_FILE, "a")
f_out:write(new_text)
f_out:close()
end
if USE_CHINADNS_NG == "0" then
api.remove(TMP_DNSMASQ_PATH)
fs.symlink(CACHE_DNS_PATH, TMP_DNSMASQ_PATH)
end
if DNSMASQ_CONF_FILE ~= "nil" then
local conf_lines = {}
if LISTEN_PORT then
--Copy dnsmasq instance
conf_lines = copy_instance({["-LISTEN_PORT"] = LISTEN_PORT, ["-TMP_DNSMASQ_PATH"] = TMP_DNSMASQ_PATH, ["-return"] = "1"})
--dhcp.leases to hosts
local hosts = "/tmp/etc/" .. appname .. "_tmp/dhcp-hosts"
sys.call("touch " .. hosts)
tinsert(conf_lines, "addn-hosts=" .. hosts)
else
--Modify the default dnsmasq service
end
if USE_CHINADNS_NG == "0" then
tinsert(conf_lines, string.format("conf-dir=%s", TMP_DNSMASQ_PATH))
end
if dnsmasq_default_dns then
for s in string.gmatch(dnsmasq_default_dns, '[^' .. "," .. ']+') do
tinsert(conf_lines, string.format("server=%s", s))
end
tinsert(conf_lines, "all-servers")
tinsert(conf_lines, "no-poll")
tinsert(conf_lines, "no-resolv")
if USE_CHINADNS_NG == "0" then
log(string.format(" - 默认:%s", dnsmasq_default_dns))
end
if FLAG == "default" then
api.set_cache_var("DEFAULT_DNS", DEFAULT_DNS)
end
end
if #conf_lines > 0 then
local conf_out = io.open(DNSMASQ_CONF_FILE, "a")
conf_out:write(table.concat(conf_lines, "\n"))
conf_out:write("\n")
conf_out:close()
end
end
if USE_CHINADNS_NG == "0" then
log(" - PassWall必须依赖于Dnsmasq,如果你自行配置了错误的DNS流程,将会导致域名(直连/代理域名)分流失效!!!")
end
end
_G.stretch = stretch
_G.restart = restart
_G.logic_restart = logic_restart
_G.copy_instance = copy_instance
_G.add_rule = add_rule
if arg[1] then
local func =_G[arg[1]]
if func then
func(api.get_function_args(arg))
end
end
@@ -0,0 +1,30 @@
#!/bin/sh
restart() {
local no_log
eval_set_val $@
_LOG_FILE=$LOG_FILE
[ -n "$no_log" ] && LOG_FILE="/dev/null"
rm -rf /tmp/smartdns.cache
/etc/init.d/smartdns reload >/dev/null 2>&1
LOG_FILE=${_LOG_FILE}
}
del() {
rm -rf /tmp/etc/smartdns/passwall.conf
sed -i "/passwall/d" /etc/smartdns/custom.conf >/dev/null 2>&1
rm -rf /tmp/smartdns.cache
/etc/init.d/smartdns reload >/dev/null 2>&1
}
arg1=$1
shift
case $arg1 in
del)
del $@
;;
restart)
restart $@
;;
*) ;;
esac
@@ -0,0 +1,696 @@
local api = require "luci.passwall.api"
local appname = api.appname
local var = api.get_args(arg)
local FLAG = var["-FLAG"]
local SMARTDNS_CONF = var["-SMARTDNS_CONF"]
local LOCAL_GROUP = var["-LOCAL_GROUP"]
local REMOTE_GROUP = var["-REMOTE_GROUP"]
local REMOTE_PROXY_SERVER = var["-REMOTE_PROXY_SERVER"]
local USE_DEFAULT_DNS = var["-USE_DEFAULT_DNS"]
local REMOTE_DNS = var["-REMOTE_DNS"]
local TUN_DNS = var["-TUN_DNS"]
local DNS_MODE = var["-DNS_MODE"]
local REMOTE_FAKEDNS = var["-REMOTE_FAKEDNS"]
local TCP_NODE = var["-TCP_NODE"]
local USE_DIRECT_LIST = var["-USE_DIRECT_LIST"]
local USE_PROXY_LIST = var["-USE_PROXY_LIST"]
local USE_BLOCK_LIST = var["-USE_BLOCK_LIST"]
local USE_GFW_LIST = var["-USE_GFW_LIST"]
local CHN_LIST = var["-CHN_LIST"]
local DEFAULT_PROXY_MODE = var["-DEFAULT_PROXY_MODE"]
local NO_PROXY_IPV6 = var["-NO_PROXY_IPV6"]
local NO_LOGIC_LOG = var["-NO_LOGIC_LOG"]
local NFTFLAG = var["-NFTFLAG"]
local SUBNET = var["-SUBNET"]
local LISTEN_PORT = var["-LISTEN_PORT"]
local LOCAL_PORT = var["-LOCAL_PORT"]
local uci = api.uci
local sys = api.sys
local fs = api.fs
local datatypes = api.datatypes
local TMP_PATH = "/tmp/etc/" .. appname
local TMP_ACL_PATH = TMP_PATH .. "/acl"
local RULES_PATH = "/usr/share/" .. appname .. "/rules"
local FLAG_PATH = TMP_ACL_PATH .. "/" .. FLAG
local TMP_CONF_FILE = FLAG_PATH .. "/smartdns.conf"
local config_lines = {}
local tmp_lines = {}
local USE_GEOVIEW = uci:get(appname, "@global_rules[0]", "enable_geoview")
local IS_SHUNT_NODE = uci:get(appname, TCP_NODE, "protocol") == "_shunt"
if IS_SHUNT_NODE then
REMOTE_FAKEDNS = uci:get(appname, TCP_NODE, "fakedns") or "0"
end
local function log(...)
if NO_LOGIC_LOG == "1" then
return
end
api.log(...)
end
local function is_file_nonzero(path)
if path and #path > 1 then
if sys.exec('[ -s "%s" ] && echo -n 1' % path) == "1" then
return true
end
end
return nil
end
local function insert_unique(dest_table, value, lookup_table)
if not lookup_table[value] then
table.insert(dest_table, value)
lookup_table[value] = true
end
end
local function merge_array(array1, array2)
for i, line in ipairs(array2) do
table.insert(array1, #array1 + 1, line)
end
end
local function insert_array_before(array1, array2, target) --将array2插入到array1的target前面,target不存在则追加
for i, line in ipairs(array1) do
if line == target then
for j = #array2, 1, -1 do
table.insert(array1, i, array2[j])
end
return
end
end
merge_array(array1, array2)
end
local function insert_array_after(array1, array2, target) --将array2插入到array1的target后面,target不存在则追加
for i, line in ipairs(array1) do
if line == target then
for j = 1, #array2 do
table.insert(array1, i + j, array2[j])
end
return
end
end
merge_array(array1, array2)
end
local function get_geosite(list_arg, out_path)
local geosite_path = uci:get(appname, "@global_rules[0]", "v2ray_location_asset") or "/usr/share/v2ray/"
geosite_path = geosite_path:match("^(.*)/") .. "/geosite.dat"
if not is_file_nonzero(geosite_path) then return 1 end
local bin = api.finded_com("geoview")
if bin and list_arg and out_path then
local cmd = string.format("%q -type geosite -append=true -input %q -list %q -output %q -lowmem=true",
bin, geosite_path, list_arg, out_path)
sys.call(cmd)
return 0
end
return 1
end
if not fs.access(FLAG_PATH) then
fs.mkdir(FLAG_PATH)
end
local LOCAL_EXTEND_ARG = ""
if LOCAL_GROUP == "null" or LOCAL_GROUP == "" then
log(" * 注意:SmartDNS 国内分组名未设置,DNS 将无法正常工作!!!")
os.exit(1)
else
--从smartdns配置中读取参数
local custom_conf_path = "/etc/smartdns/custom.conf"
local options = {
{key = "dualstack_ip_selection", config_key = "dualstack-ip-selection", yes_no = true, arg_yes = "-d yes", arg_no = "-d no", default = "yes"},
{key = "speed_check_mode", config_key = "speed-check-mode", prefix = "-c ", default = "ping,tcp:80,tcp:443"},
{key = "serve_expired", config_key = "serve-expired", yes_no = true, arg_yes = "", arg_no = "-no-serve-expired", default = "yes"},
{key = "response_mode", config_key = "response-mode", prefix = "-r ", default = "first-ping"},
{key = "rr_ttl", config_key = "rr-ttl", prefix = "-rr-ttl "},
{key = "rr_ttl_min", config_key = "rr-ttl-min", prefix = "-rr-ttl-min "},
{key = "rr_ttl_max", config_key = "rr-ttl-max", prefix = "-rr-ttl-max "},
{key = "rr_ttl_reply_max", config_key = "rr-ttl-reply-max", prefix = "-rr-ttl-reply-max "},
{
key = "force_aaaa_soa",
config_key = "force-qtype-SOA",
prefix = "-address ",
get_value = function(custom_config)
local soa = custom_config["force-qtype-SOA"]
return ((soa and soa:match("(^|%s)28(%s|$)"))
or custom_config["force-AAAA-SOA"] == "yes"
or uci:get("smartdns", "@smartdns[0]", "force_aaaa_soa") == "1")
and "#6" or "-6"
end
}
}
-- 从 custom.conf 中读取值,以最后出现的值为准
local custom_config = {}
local f_in = io.open(custom_conf_path, "r")
if f_in then
for line in f_in:lines() do
line = api.trim(line)
if line ~= "" and not line:match("^#") then
local param, value = line:match("^(%S+)%s+(.+)$")
if param and value then custom_config[param] = value end
end
end
f_in:close()
end
-- 从 smartdns 配置中读取值,优先级以 custom.conf 为准
for _, opt in ipairs(options) do
local val
if opt.get_value then
val = opt.get_value(custom_config)
else
val = custom_config[opt.config_key] or uci:get("smartdns", "@smartdns[0]", opt.key) or opt.default
end
if val == "yes" then val = "1" elseif val == "no" then val = "0" end
if opt.yes_no then
local arg = (val == "1" and opt.arg_yes or opt.arg_no)
if arg and arg ~= "" then
LOCAL_EXTEND_ARG = LOCAL_EXTEND_ARG .. (LOCAL_EXTEND_ARG ~= "" and " " or "") .. arg
end
else
if val and (not opt.value or (opt.invert and val ~= opt.value) or (not opt.invert and val == opt.value)) then
LOCAL_EXTEND_ARG = LOCAL_EXTEND_ARG .. (LOCAL_EXTEND_ARG ~= "" and " " or "") .. (opt.prefix or "") .. (opt.arg or val)
end
end
end
end
if not REMOTE_GROUP or REMOTE_GROUP == "nil" then
REMOTE_GROUP = "passwall_proxy"
if REMOTE_DNS then
REMOTE_DNS = REMOTE_DNS:gsub("#", ":")
end
sys.call('sed -i "/passwall/d" /etc/smartdns/custom.conf >/dev/null 2>&1')
end
local force_https_soa = uci:get(appname, "@global[0]", "force_https_soa") or 0
local proxy_server_name = "passwall-proxy-server"
config_lines = {
tonumber(LISTEN_PORT) ~= 0 and "bind [::]:" .. LISTEN_PORT .. "@lo" or "",
(tonumber(LOCAL_PORT) ~= 0 and LOCAL_GROUP) and "bind [::]:" .. LOCAL_PORT .. "@lo -group " .. LOCAL_GROUP or "",
tonumber(force_https_soa) == 1 and "force-qtype-SOA 65" or "force-qtype-SOA -,65",
"server 223.5.5.5 -bootstrap-dns",
is_file_nonzero("/etc/hosts") and "hosts-file /etc/hosts" or "",
DNS_MODE == "socks" and string.format("proxy-server socks5://%s -name %s", REMOTE_PROXY_SERVER, proxy_server_name) or ""
}
if DNS_MODE == "socks" then
for w in string.gmatch(REMOTE_DNS, '[^|]+') do
local server_dns = api.trim(w)
local server_param
local dnsType = string.match(server_dns, "^(.-)://")
dnsType = dnsType and string.lower(dnsType) or nil
local dnsServer = string.match(server_dns, "://(.+)") or server_dns
if dnsType and dnsType ~= "" and dnsType ~= "udp" then
if dnsType == "tcp" then
server_param = "server-tcp " .. dnsServer
elseif dnsType == "tls" then
server_param = "server-tls " .. dnsServer
elseif dnsType == "quic" then
server_param = "server-quic " .. dnsServer
elseif dnsType == "https" or dnsType == "h3" then
local http_host = nil
local url = w
local port = 443
local s = api.split(w, ",")
if s and #s > 1 then
url = s[1]
local dns_ip = s[2]
local host_port = api.get_domain_from_url(s[1])
if host_port and #host_port > 0 then
http_host = host_port
local s2 = api.split(host_port, ":")
if s2 and #s2 > 1 then
http_host = s2[1]
port = s2[2]
end
url = url:gsub(http_host, dns_ip)
end
end
server_dns = url
if http_host then
server_dns = server_dns .. " -http-host " .. http_host
end
server_param = (dnsType == "https" and "server-https " or "server-h3 ") .. server_dns
end
else
server_param = "server " .. dnsServer
end
if not api.is_local_ip(w) then
server_param = server_param .. " -proxy " .. proxy_server_name
end
server_param = server_param .. " -group " .. REMOTE_GROUP .. " -exclude-default-group"
if SUBNET and SUBNET ~= "" and SUBNET ~= "0" then
server_param = server_param .. " -subnet " .. SUBNET
end
table.insert(config_lines, server_param)
end
if not IS_SHUNT_NODE then REMOTE_FAKEDNS = "0" end
else
local server_param = string.format("server %s -group %s -exclude-default-group", TUN_DNS:gsub("#", ":"), REMOTE_GROUP)
table.insert(config_lines, server_param)
log(" - " .. DNS_MODE:gsub("^%l",string.upper) .. " " .. TUN_DNS .. " -> " .. REMOTE_GROUP)
end
--设置默认 DNS 分组(托底组)
local DEFAULT_DNS_GROUP = (USE_DEFAULT_DNS == "direct" and LOCAL_GROUP) or
(USE_DEFAULT_DNS == "remote" and REMOTE_GROUP)
local only_global = (DEFAULT_PROXY_MODE == "proxy" and CHN_LIST == "0" and USE_GFW_LIST == "0") and 1 --没有启用中国列表和GFW列表时(全局)
if only_global == 1 then
DEFAULT_DNS_GROUP = REMOTE_GROUP
end
if DEFAULT_DNS_GROUP then
local domain_rules_str = "domain-rules /./ -nameserver " .. DEFAULT_DNS_GROUP
if DEFAULT_DNS_GROUP == REMOTE_GROUP then
domain_rules_str = domain_rules_str .. " -speed-check-mode none -d no -no-serve-expired"
domain_rules_str = domain_rules_str .. " -address " .. (NO_PROXY_IPV6 == "1" and "#6" or "-6")
elseif DEFAULT_DNS_GROUP == LOCAL_GROUP then
domain_rules_str = domain_rules_str .. (LOCAL_EXTEND_ARG ~= "" and " " .. LOCAL_EXTEND_ARG or "")
end
table.insert(config_lines, domain_rules_str)
end
local setflag = (NFTFLAG == "1") and "inet#passwall#" or ""
local set_type = (NFTFLAG == "1") and "-nftset" or "-ipset"
--预设排序标签(越往后优先级越高)
for i = 1, 8 do
table.insert(config_lines, "#--" .. i)
end
--屏蔽列表
local file_block_host = TMP_ACL_PATH .. "/block_host"
if USE_BLOCK_LIST == "1" and not fs.access(file_block_host) then
local block_domain, lookup_block_domain = {}, {}
local geosite_arg = ""
for line in io.lines(RULES_PATH .. "/block_host") do
if not line:find("#") and line:find("geosite:") then
line = string.match(line, ":([^:]+)$")
geosite_arg = geosite_arg .. (geosite_arg ~= "" and "," or "") .. line
else
line = api.get_std_domain(line)
if line ~= "" and not line:find("#") then
insert_unique(block_domain, line, lookup_block_domain)
end
end
end
if #block_domain > 0 then
local f_out = io.open(file_block_host, "w")
for i = 1, #block_domain do
f_out:write(block_domain[i] .. "\n")
end
f_out:close()
end
if USE_GEOVIEW == "1" and geosite_arg ~= "" and api.is_finded("geoview") then
if get_geosite(geosite_arg, file_block_host) == 0 then
log(" * 解析[屏蔽列表] Geosite 到屏蔽域名表(blocklist)完成")
else
log(" * 解析[屏蔽列表] Geosite 到屏蔽域名表(blocklist)失败!")
end
end
end
if USE_BLOCK_LIST == "1" and is_file_nonzero(file_block_host) then
local domain_set_name = "passwall-block"
tmp_lines = {
string.format("domain-set -name %s -file %s", domain_set_name, file_block_host),
string.format("domain-rules /domain-set:%s/ -a #", domain_set_name)
}
insert_array_after(config_lines, tmp_lines, "#--7")
end
--始终用国内DNS解析节点域名
local file_vpslist = TMP_ACL_PATH .. "/vpslist"
if not is_file_nonzero(file_vpslist) then
local f_out = io.open(file_vpslist, "w")
local written_domains = {}
local function process_address(address)
address = (address or ""):lower()
if api.vps_domain_exclude(address) then return end
if datatypes.hostname(address) and not written_domains[address] then
f_out:write(address .. "\n")
written_domains[address] = true
end
end
uci:foreach(appname, "nodes", function(t)
process_address(t.address)
process_address(t.download_address)
end)
uci:foreach(appname, "subscribe_list", function(t) --订阅链接
local url, _ = api.get_domain_port_from_url(t.url or "")
if url and url ~= "" then
process_address(url)
end
end)
f_out:close()
end
if is_file_nonzero(file_vpslist) then
local domain_set_name = "passwall-vpslist"
tmp_lines = {
string.format("domain-set -name %s -file %s", domain_set_name, file_vpslist)
}
local sets = {
"#4:" .. setflag .. "psw_vps",
"#6:" .. setflag .. "psw_vps6"
}
local domain_rules_str = string.format('domain-rules /domain-set:%s/ %s', domain_set_name, LOCAL_GROUP and "-nameserver " .. LOCAL_GROUP or "")
domain_rules_str = domain_rules_str .. " " .. set_type .. " " .. table.concat(sets, ",")
domain_rules_str = domain_rules_str .. (LOCAL_EXTEND_ARG ~= "" and " " .. LOCAL_EXTEND_ARG or "")
table.insert(tmp_lines, domain_rules_str)
insert_array_after(config_lines, tmp_lines, "#--8")
log(string.format(" - 节点列表中的域名(vpslist)使用分组:%s", LOCAL_GROUP or "默认"))
end
--直连(白名单)列表
local file_direct_host = TMP_ACL_PATH .. "/direct_host"
if USE_DIRECT_LIST == "1" and not fs.access(file_direct_host) then
local direct_domain, lookup_direct_domain = {}, {}
local geosite_arg = ""
for line in io.lines(RULES_PATH .. "/direct_host") do
if not line:find("#") and line:find("geosite:") then
line = string.match(line, ":([^:]+)$")
geosite_arg = geosite_arg .. (geosite_arg ~= "" and "," or "") .. line
else
line = api.get_std_domain(line)
if line ~= "" and not line:find("#") then
insert_unique(direct_domain, line, lookup_direct_domain)
end
end
end
if #direct_domain > 0 then
local f_out = io.open(file_direct_host, "w")
for i = 1, #direct_domain do
f_out:write(direct_domain[i] .. "\n")
end
f_out:close()
end
if USE_GEOVIEW == "1" and geosite_arg ~= "" and api.is_finded("geoview") then
if get_geosite(geosite_arg, file_direct_host) == 0 then
log(" * 解析[直连列表] Geosite 到域名白名单(whitelist)完成")
else
log(" * 解析[直连列表] Geosite 到域名白名单(whitelist)失败!")
end
end
end
if USE_DIRECT_LIST == "1" and is_file_nonzero(file_direct_host) then
local domain_set_name = "passwall-directlist"
tmp_lines = {
string.format("domain-set -name %s -file %s", domain_set_name, file_direct_host)
}
local sets = {
"#4:" .. setflag .. "psw_white",
"#6:" .. setflag .. "psw_white6"
}
local domain_rules_str = string.format('domain-rules /domain-set:%s/ %s', domain_set_name, LOCAL_GROUP and "-nameserver " .. LOCAL_GROUP or "")
domain_rules_str = domain_rules_str .. " " .. set_type .. " " .. table.concat(sets, ",")
domain_rules_str = domain_rules_str .. (LOCAL_EXTEND_ARG ~= "" and " " .. LOCAL_EXTEND_ARG or "")
table.insert(tmp_lines, domain_rules_str)
insert_array_after(config_lines, tmp_lines, "#--6")
log(string.format(" - 域名白名单(whitelist)使用分组:%s", LOCAL_GROUP or "默认"))
end
--代理(黑名单)列表
local file_proxy_host = TMP_ACL_PATH .. "/proxy_host"
if USE_PROXY_LIST == "1" and not fs.access(file_proxy_host) then
local proxy_domain, lookup_proxy_domain = {}, {}
local geosite_arg = ""
for line in io.lines(RULES_PATH .. "/proxy_host") do
if not line:find("#") and line:find("geosite:") then
line = string.match(line, ":([^:]+)$")
geosite_arg = geosite_arg .. (geosite_arg ~= "" and "," or "") .. line
else
line = api.get_std_domain(line)
if line ~= "" and not line:find("#") then
insert_unique(proxy_domain, line, lookup_proxy_domain)
end
end
end
if #proxy_domain > 0 then
local f_out = io.open(file_proxy_host, "w")
for i = 1, #proxy_domain do
f_out:write(proxy_domain[i] .. "\n")
end
f_out:close()
end
if USE_GEOVIEW == "1" and geosite_arg ~= "" and api.is_finded("geoview") then
if get_geosite(geosite_arg, file_proxy_host) == 0 then
log(" * 解析[代理列表] Geosite 到代理域名表(blacklist)完成")
else
log(" * 解析[代理列表] Geosite 到代理域名表(blacklist)失败!")
end
end
end
if USE_PROXY_LIST == "1" and is_file_nonzero(file_proxy_host) then
local domain_set_name = "passwall-proxylist"
tmp_lines = {
string.format("domain-set -name %s -file %s", domain_set_name, file_proxy_host)
}
local domain_rules_str = string.format('domain-rules /domain-set:%s/ -nameserver %s', domain_set_name, REMOTE_GROUP)
domain_rules_str = domain_rules_str .. " -speed-check-mode none"
domain_rules_str = domain_rules_str .. " -no-serve-expired"
local sets = {
"#4:" .. setflag .. "psw_black"
}
if NO_PROXY_IPV6 == "1" then
domain_rules_str = domain_rules_str .. " -address #6"
domain_rules_str = REMOTE_FAKEDNS ~= "1" and (domain_rules_str .. " " .. set_type .. " " .. table.concat(sets, ",")) or domain_rules_str
else
domain_rules_str = domain_rules_str .. " -address -6"
table.insert(sets, "#6:" .. setflag .. "psw_black6")
domain_rules_str = REMOTE_FAKEDNS ~= "1" and (domain_rules_str .. " -d no " .. set_type .. " " .. table.concat(sets, ",")) or domain_rules_str
end
table.insert(tmp_lines, domain_rules_str)
insert_array_after(config_lines, tmp_lines, "#--5")
log(string.format(" - 代理域名表(blacklist)使用分组:%s", REMOTE_GROUP or "默认"))
end
--GFW列表
if USE_GFW_LIST == "1" and is_file_nonzero(RULES_PATH .. "/gfwlist") then
local domain_set_name = "passwall-gfwlist"
tmp_lines = {
string.format("domain-set -name %s -file %s", domain_set_name, RULES_PATH .. "/gfwlist")
}
local domain_rules_str = string.format('domain-rules /domain-set:%s/ -nameserver %s', domain_set_name, REMOTE_GROUP)
domain_rules_str = domain_rules_str .. " -speed-check-mode none"
domain_rules_str = domain_rules_str .. " -no-serve-expired"
local sets = {
"#4:" .. setflag .. "psw_gfw"
}
if NO_PROXY_IPV6 == "1" then
domain_rules_str = domain_rules_str .. " -address #6"
domain_rules_str = REMOTE_FAKEDNS ~= "1" and (domain_rules_str .. " " .. set_type .. " " .. table.concat(sets, ",")) or domain_rules_str
else
domain_rules_str = domain_rules_str .. " -address -6"
table.insert(sets, "#6:" .. setflag .. "psw_gfw6")
domain_rules_str = REMOTE_FAKEDNS ~= "1" and (domain_rules_str .. " -d no " .. set_type .. " " .. table.concat(sets, ",")) or domain_rules_str
end
table.insert(tmp_lines, domain_rules_str)
insert_array_after(config_lines, tmp_lines, "#--1")
log(string.format(" - 防火墙域名表(gfwlist)使用分组:%s", REMOTE_GROUP or "默认"))
end
--中国列表
if CHN_LIST ~= "0" and is_file_nonzero(RULES_PATH .. "/chnlist") then
local domain_set_name = "passwall-chnlist"
tmp_lines = {
string.format("domain-set -name %s -file %s", domain_set_name, RULES_PATH .. "/chnlist")
}
if CHN_LIST == "direct" then
local sets = {
"#4:" .. setflag .. "psw_chn",
"#6:" .. setflag .. "psw_chn6"
}
local domain_rules_str = string.format('domain-rules /domain-set:%s/ %s', domain_set_name, LOCAL_GROUP and "-nameserver " .. LOCAL_GROUP or "")
domain_rules_str = domain_rules_str .. " " .. set_type .. " " .. table.concat(sets, ",")
domain_rules_str = domain_rules_str .. (LOCAL_EXTEND_ARG ~= "" and " " .. LOCAL_EXTEND_ARG or "")
table.insert(tmp_lines, domain_rules_str)
insert_array_after(config_lines, tmp_lines, "#--2")
log(string.format(" - 中国域名表(chnroute)使用分组:%s", LOCAL_GROUP or "默认"))
end
--回中国模式
if CHN_LIST == "proxy" then
local domain_rules_str = string.format('domain-rules /domain-set:%s/ -nameserver %s', domain_set_name, REMOTE_GROUP)
domain_rules_str = domain_rules_str .. " -speed-check-mode none"
domain_rules_str = domain_rules_str .. " -no-serve-expired"
local sets = {
"#4:" .. setflag .. "psw_chn"
}
if NO_PROXY_IPV6 == "1" then
domain_rules_str = domain_rules_str .. " -address #6"
domain_rules_str = REMOTE_FAKEDNS ~= "1" and (domain_rules_str .. " " .. set_type .. " " .. table.concat(sets, ",")) or domain_rules_str
else
domain_rules_str = domain_rules_str .. " -address -6"
table.insert(sets, "#6:" .. setflag .. "psw_chn6")
domain_rules_str = REMOTE_FAKEDNS ~= "1" and (domain_rules_str .. " -d no " .. set_type .. " " .. table.concat(sets, ",")) or domain_rules_str
end
table.insert(tmp_lines, domain_rules_str)
insert_array_after(config_lines, tmp_lines, "#--2")
log(string.format(" - 中国域名表(chnroute)使用分组:%s", REMOTE_GROUP or "默认"))
end
end
--分流规则
if IS_SHUNT_NODE then
local white_domain, lookup_white_domain = {}, {}
local shunt_domain, lookup_shunt_domain = {}, {}
local file_white_host = FLAG_PATH .. "/shunt_direct_host"
local file_shunt_host = FLAG_PATH .. "/shunt_proxy_host"
local geosite_white_arg, geosite_shunt_arg = "", ""
local t = uci:get_all(appname, TCP_NODE)
local default_node_id = t["default_node"] or "_direct"
uci:foreach(appname, "shunt_rules", function(s)
local _node_id = t[s[".name"]]
if _node_id and _node_id ~= "_blackhole" then
if _node_id == "_default" then
_node_id = default_node_id
end
local domain_list = s.domain_list or ""
for line in string.gmatch(domain_list, "[^\r\n]+") do
if line ~= "" and not line:find("#") and not line:find("regexp:") and not line:find("ext:") then
if line:find("geosite:") then
line = string.match(line, ":([^:]+)$")
if _node_id == "_direct" then
geosite_white_arg = geosite_white_arg .. (geosite_white_arg ~= "" and "," or "") .. line
else
geosite_shunt_arg = geosite_shunt_arg .. (geosite_shunt_arg ~= "" and "," or "") .. line
end
else
if line:find("domain:") or line:find("full:") then
line = string.match(line, ":([^:]+)$")
end
line = api.get_std_domain(line)
if line ~= "" and not line:find("#") then
if _node_id == "_direct" then
insert_unique(white_domain, line, lookup_white_domain)
else
insert_unique(shunt_domain, line, lookup_shunt_domain)
end
end
end
end
end
if _node_id ~= "_direct" then
log(string.format(" - Sing-Box/Xray分流规则(%s)使用分组:%s", s.remarks, REMOTE_GROUP or "默认"))
end
end
end)
if is_file_nonzero(file_white_host) == nil then
if #white_domain > 0 then
local f_out = io.open(file_white_host, "w")
for i = 1, #white_domain do
f_out:write(white_domain[i] .. "\n")
end
f_out:close()
end
end
if is_file_nonzero(file_shunt_host) == nil then
if #shunt_domain > 0 then
local f_out = io.open(file_shunt_host, "w")
for i = 1, #shunt_domain do
f_out:write(shunt_domain[i] .. "\n")
end
f_out:close()
end
end
if USE_GFW_LIST == "1" and CHN_LIST == "0" and USE_GEOVIEW == "1" and api.is_finded("geoview") then --仅GFW模式解析geosite
local return_white, return_shunt
if geosite_white_arg ~= "" then
return_white = get_geosite(geosite_white_arg, file_white_host)
end
if geosite_shunt_arg ~= "" then
return_shunt = get_geosite(geosite_shunt_arg, file_shunt_host)
end
if (return_white == nil or return_white == 0) and (return_shunt == nil or return_shunt == 0) then
log(" * 解析[分流节点] Geosite 完成")
else
log(" * 解析[分流节点] Geosite 失败!")
end
end
if is_file_nonzero(file_white_host) then
local domain_set_name = "passwall-whitehost"
tmp_lines = {
string.format("domain-set -name %s -file %s", domain_set_name, file_white_host)
}
local domain_rules_str = string.format('domain-rules /domain-set:%s/ %s', domain_set_name, LOCAL_GROUP and "-nameserver " .. LOCAL_GROUP or "")
if USE_DIRECT_LIST == "1" then
local sets = {
"#4:" .. setflag .. "psw_white",
"#6:" .. setflag .. "psw_white6"
}
domain_rules_str = domain_rules_str .. " " .. set_type .. " " .. table.concat(sets, ",")
else
local sets = {
"#4:" .. setflag .. "psw_shunt",
"#6:" .. setflag .. "psw_shunt6"
}
domain_rules_str = domain_rules_str .. " " .. set_type .. " " .. table.concat(sets, ",")
end
domain_rules_str = domain_rules_str .. (LOCAL_EXTEND_ARG ~= "" and " " .. LOCAL_EXTEND_ARG or "")
table.insert(tmp_lines, domain_rules_str)
insert_array_after(config_lines, tmp_lines, "#--4")
end
if is_file_nonzero(file_shunt_host) then
local domain_set_name = "passwall-shuntlist"
tmp_lines = {
string.format("domain-set -name %s -file %s", domain_set_name, file_shunt_host)
}
local domain_rules_str = string.format('domain-rules /domain-set:%s/ -nameserver %s', domain_set_name, REMOTE_GROUP)
domain_rules_str = domain_rules_str .. " -speed-check-mode none"
domain_rules_str = domain_rules_str .. " -no-serve-expired"
local sets = {
"#4:" .. setflag .. "psw_shunt"
}
if NO_PROXY_IPV6 == "1" then
domain_rules_str = domain_rules_str .. " -address #6"
domain_rules_str = (not only_global and REMOTE_FAKEDNS == "1")
and domain_rules_str
or (domain_rules_str .. " " .. set_type .. " " .. table.concat(sets, ","))
else
domain_rules_str = domain_rules_str .. " -address -6"
table.insert(sets, "#6:" .. setflag .. "psw_shunt6")
domain_rules_str = (not only_global and REMOTE_FAKEDNS == "1")
and domain_rules_str
or (domain_rules_str .. " -d no " .. set_type .. " " .. table.concat(sets, ","))
end
table.insert(tmp_lines, domain_rules_str)
insert_array_after(config_lines, tmp_lines, "#--3")
end
end
if #config_lines > 0 then
local f_out = io.open(TMP_CONF_FILE, "w")
for i = 1, #config_lines do
line = config_lines[i]
if line ~= "" and not line:find("^#--") then
f_out:write(line .. "\n")
end
end
f_out:close()
end
if DEFAULT_DNS_GROUP then
log(string.format(" - 默认 DNS 分组:%s", DEFAULT_DNS_GROUP))
end
fs.symlink(TMP_CONF_FILE, SMARTDNS_CONF)
sys.call(string.format('echo "conf-file %s" >> /etc/smartdns/custom.conf', string.gsub(SMARTDNS_CONF, appname, appname .. "*")))
log(" - SmartDNS已作为Dnsmasq上游,如果你自行配置了错误的DNS流程,将会导致域名(直连/代理域名)分流失效!!!")
File diff suppressed because it is too large. Load diff
+51
View File
@@ -0,0 +1,51 @@
#!/bin/sh
# dhcp.leases to hosts
. /usr/share/passwall/utils.sh
LOCK_FILE=${LOCK_PATH}/${CONFIG}_lease2hosts.lock
LEASE_FILE="/tmp/dhcp.leases"
HOSTS_FILE="$TMP_PATH2/dhcp-hosts"
TMP_FILE="/tmp/dhcp-hosts.tmp"
exec 99>"$LOCK_FILE"
flock -n 99
if [ "$?" != 0 ]; then
exit 0
fi
reload_dnsmasq_pids() {
local pidfile pid
find $TMP_PATH/acl -type f -name 'dnsmasq.pid' 2>/dev/null | while read pidfile; do
if [ -s "$pidfile" ]; then
read pid < "$pidfile"
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
kill -HUP "$pid"
fi
fi
done
}
while true; do
if [ -f "$LEASE_FILE" ]; then
awk 'NF >= 4 && $4 != "*" {print $3" "$4}' "$LEASE_FILE" | sort > "$TMP_FILE"
if [ -s "$TMP_FILE" ]; then
if [ ! -f "$HOSTS_FILE" ] || ! cmp -s "$TMP_FILE" "$HOSTS_FILE"; then
mv "$TMP_FILE" "$HOSTS_FILE"
reload_dnsmasq_pids
else
rm -f "$TMP_FILE"
fi
else
if [ -s "$HOSTS_FILE" ]; then
: > "$HOSTS_FILE"
reload_dnsmasq_pids
fi
rm -f "$TMP_FILE"
fi
fi
sleep 60
done 2>/dev/null
+59
View File
@@ -0,0 +1,59 @@
#!/bin/sh
DIR="$(cd "$(dirname "$0")" && pwd)"
. $DIR/utils.sh
LOCK_FILE=${LOCK_PATH}/${CONFIG}_monitor.lock
MAX_RESTART_COUNT=10
RESTART_STATS_DIR="${TMP_PATH}/script_rstats"
mkdir -p "$RESTART_STATS_DIR"
sleep 58s
last_cleanup_date=$(date +%Y%m%d)
while [ 1 -eq 1 ]; do
[ -f "$LOCK_FILE" ] && {
sleep 6s
continue
}
touch $LOCK_FILE
for file in "$TMP_SCRIPT_FUNC_PATH"/*; do
[ -f "$file" ] || continue
IFS= read -r cmd < "$file"
[ -z "$cmd" ] && continue
cmd_check=$(printf '%s' "$cmd" | sed 's/>.*$//;s/[[:space:]]*$//')
case "$cmd_check" in
*dns2socks*) cmd_check=${cmd_check//:/ } ;;
esac
filename=$(basename "$file")
stats_file="${RESTART_STATS_DIR}/${filename}.count"
if [ -s "$stats_file" ]; then
read restart_count < "$stats_file"
[ -z "$restart_count" ] && restart_count=0
else
restart_count=0
fi
# 检查是否超过最大重启次数
[ "$restart_count" -ge "$MAX_RESTART_COUNT" ] && continue
if ! busybox pgrep -f "$cmd_check" >/dev/null; then
restart_count=$((restart_count + 1))
echo "$restart_count" > "$stats_file"
#echo "${cmd} 进程挂掉,重启" >> /tmp/log/passwall.log
sh -c "nohup $cmd 2>&1 &"
sleep 1
fi
done
# 每天清理一次统计文件(跨天后执行一次)
current_date=$(date +%Y%m%d)
if [ "$current_date" != "$last_cleanup_date" ]; then
rm -f "${RESTART_STATS_DIR:?}"/* 2>/dev/null
last_cleanup_date="$current_date"
fi
rm -f $LOCK_FILE
sleep 58s
done
File diff suppressed because it is too large. Load diff
+801
View File
@@ -0,0 +1,801 @@
#!/usr/bin/lua
local api = require ("luci.passwall.api")
local name = api.appname
local uci = api.uci
local sys = api.sys
local jsonc = api.jsonc
local fs = api.fs
local arg1 = arg[1]
local arg2 = arg[2]
local arg3 = arg[3]
local nftable_name = "inet passwall"
local rule_path = "/usr/share/" .. name .. "/rules"
local reboot = 0
local gfwlist_update = "0"
local chnroute_update = "0"
local chnroute6_update = "0"
local chnlist_update = "0"
local geoip_update = "0"
local geosite_update = "0"
local excluded_domain = {"apple.com","sina.cn","sina.com.cn","baidu.com","byr.cn","jlike.com","weibo.com","zhongsou.com","youdao.com","sogou.com","so.com","soso.com","aliyun.com","taobao.com","jd.com","qq.com","bing.com"}
local gfwlist_url = uci:get(name, "@global_rules[0]", "gfwlist_url") or {"https://fastly.jsdelivr.net/gh/Loyalsoldier/v2ray-rules-dat@release/gfw.txt"}
local chnroute_url = uci:get(name, "@global_rules[0]", "chnroute_url") or {"https://ispip.clang.cn/all_cn.txt"}
local chnroute6_url = uci:get(name, "@global_rules[0]", "chnroute6_url") or {"https://ispip.clang.cn/all_cn_ipv6.txt"}
local chnlist_url = uci:get(name, "@global_rules[0]", "chnlist_url") or {"https://fastly.jsdelivr.net/gh/felixonmars/dnsmasq-china-list/accelerated-domains.china.conf","https://fastly.jsdelivr.net/gh/felixonmars/dnsmasq-china-list/apple.china.conf","https://fastly.jsdelivr.net/gh/felixonmars/dnsmasq-china-list/google.china.conf"}
local geoip_url = uci:get(name, "@global_rules[0]", "geoip_url") or "https://github.com/Loyalsoldier/geoip/releases/latest/download/geoip.dat"
local geosite_url = uci:get(name, "@global_rules[0]", "geosite_url") or "https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat"
local asset_location = uci:get(name, "@global_rules[0]", "v2ray_location_asset") or "/usr/share/v2ray/"
local geo2rule = uci:get(name, "@global_rules[0]", "geo2rule") or "0"
local geoip_update_ok, geosite_update_ok = false, false
asset_location = asset_location:match("/$") and asset_location or (asset_location .. "/")
local backup_path = "/tmp/bak_v2ray/"
local rollback = false
if arg3 == "cron" then
arg2 = nil
elseif arg3 == "rollback" then
rollback, geoip_update_ok, geosite_update_ok = true, true, true
end
local log = function(...)
if arg1 then
if arg1 == "log" then
api.log(...)
elseif arg1 == "print" then
local result = os.date("%Y-%m-%d %H:%M:%S: ") .. table.concat({...}, " ")
print(result)
end
end
end
--gen cache for nftset from file
local function gen_cache(set_name, ip_type, input_file, output_file)
local tmp_set_name = set_name .. "_tmp_" .. os.time()
local f_in = io.open(input_file, "r")
if not f_in then return false end
local nft_pipe = io.popen("nft -f -", "w")
if not nft_pipe then
f_in:close()
return false
end
nft_pipe:write('#!/usr/sbin/nft -f\n')
nft_pipe:write(string.format('add table %s\n', nftable_name))
nft_pipe:write(string.format('add set %s %s { type %s; flags interval, timeout; timeout 2d; gc-interval 1h; auto-merge; }\n', nftable_name, tmp_set_name, ip_type))
nft_pipe:write(string.format('add element %s %s { ', nftable_name, tmp_set_name))
local count = 0
local batch_size = 500
for line in f_in:lines() do
local ip = line:match("^%s*(.-)%s*$")
if ip and ip ~= "" then
nft_pipe:write(ip, "timeout 365d, ")
count = count + 1
if count % batch_size == 0 then
nft_pipe:write("}\n")
nft_pipe:write(string.format('add element %s %s { ', nftable_name, tmp_set_name))
end
end
end
nft_pipe:write("}\n")
f_in:close()
local success = nft_pipe:close()
if not (success == true or success == 0) then
os.execute(string.format('nft delete set %s %s 2>/dev/null', nftable_name, tmp_set_name))
return false
end
os.execute(string.format('nft list set %s %s | sed "s/%s/%s/g" > %s', nftable_name, tmp_set_name, tmp_set_name, set_name, output_file))
os.execute(string.format('nft delete set %s %s 2>/dev/null', nftable_name, tmp_set_name))
end
-- curl
local function curl(url, file)
local http_code = 0
local header_str = ""
local args = {
"-fskL",
"--retry 3",
"--connect-timeout 3",
"--max-time 300",
"--speed-limit 51200 --speed-time 15",
'-A "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36"',
"--dump-header -",
"-w '\\n%{http_code}'"
}
if file then
args[#args + 1] = "-o " .. file
end
local return_code, result = api.curl_auto(url, nil, args)
if result and result ~= "" then
local body, code = result:match("^(.-)%s*([0-9]+)$")
if code then
http_code = tonumber(code) or 0
header_str = body
else
http_code = tonumber(result:match("(%d+)%s*$")) or 0
end
end
if header_str ~= "" then
header_str = header_str:gsub("\r", "")
end
return return_code, http_code, header_str
end
--check excluded domain
local excluded_map = {}
for _, d in ipairs(excluded_domain) do
excluded_map[d] = true
end
local function check_excluded_domain(value)
if not value or value == "" then return false end
value = value:lower()
local eq_pos = value:find("=", 1, true)
if eq_pos then
value = value:sub(eq_pos + 1)
end
if value:sub(1,1) == "/" then
value = value:sub(2)
end
local slash_pos = value:find("/", 1, true)
local colon_pos = value:find(":", 1, true)
local cut_pos
if slash_pos and colon_pos then
cut_pos = (slash_pos < colon_pos) and slash_pos or colon_pos
else
cut_pos = slash_pos or colon_pos
end
if cut_pos then
value = value:sub(1, cut_pos - 1)
end
value = value:gsub("^%.*", ""):gsub("%.*$", "")
while value do
if excluded_map[value] then
return true
end
local dot_pos = value:find(".", 1, true)
if not dot_pos then
break
end
value = value:sub(dot_pos + 1)
end
return false
end
-- 替代 string.find 查找 "^[#!\\[@]+"
local function is_comment_line(s)
if not s or s == "" then return false end
local b = s:byte(1)
-- '#' = 35, '!' = 33, '\' = 92, '[' = 91, '@' = 64
if b == 35 or b == 33 or b == 92 or b == 91 or b == 64 then
return true
end
return false
end
-- IPv4 检测,替代 string.find "^%d+%.%d+%.%d+%.%d+"
-- IPv4 cidr检测,替代 string.find "^%d+%.%d+%.%d+%.%d+[%/][%d]+$"
local function is_ipv4(s, check_cidr)
local dot = 0
local seg_start = 1
local len = #s
local mask_start = nil
local i = 1
while i <= len do
local b = s:byte(i)
if b >= 48 and b <= 57 then
-- 数字,继续
elseif b == 46 then -- "."
dot = dot + 1
if dot > 3 or i == seg_start then return false end
local seg = tonumber(s:sub(seg_start, i - 1))
if not seg or seg > 255 then return false end
seg_start = i + 1
elseif b == 47 then -- "/"
if not check_cidr then return false end
if dot ~= 3 or i == seg_start then return false end
local seg = tonumber(s:sub(seg_start, i - 1))
if not seg or seg > 255 then return false end
mask_start = i + 1
break
else
return false
end
i = i + 1
end
-- 如果没有 CIDR,则检查最后一段即可
if not check_cidr or not mask_start then
if dot ~= 3 or seg_start > len then return false end
local seg = tonumber(s:sub(seg_start))
return seg and seg <= 255 or false
end
-- CIDR 掩码检查
if mask_start > len then return false end
local mask = tonumber(s:sub(mask_start))
return mask and mask >= 0 and mask <= 32 or false
end
local function is_ipv4_cidr(s)
return is_ipv4(s, true)
end
local function is_ipv6(s, check_cidr)
local first = s:byte(1)
local last = s:byte(#s)
if first == 91 and last == 93 then -- "[" and "]"
s = s:sub(2, -2)
end
local len = #s
local i = 1
local seg_len = 0
local segs = 0
local saw_dc = false -- 是否出现 "::"
local b
while i <= len do
b = s:byte(i)
-- CIDR 部分
if b == 47 then -- '/'
if not check_cidr then
return false
end
-- 处理 "/" 之前的段
if seg_len > 0 then segs = segs + 1 end
if (not saw_dc and segs ~= 8) or (saw_dc and segs > 8) then return false end
-- 解析掩码
i = i + 1
if i > len then return false end
local mask = 0
while i <= len do
b = s:byte(i)
if b < 48 or b > 57 then return false end
mask = mask * 10 + (b - 48)
if mask > 128 then return false end
i = i + 1
end
-- CIDR 解析成功
return true
end
-- 冒号处理(: 或 ::)
if b == 58 then
local nextb = (i+1 <= len) and s:byte(i+1) or 0
-- "::"
if nextb == 58 then
if saw_dc then return false end
saw_dc = true
if seg_len > 0 then segs = segs + 1 end
seg_len = 0
i = i + 2
else
-- 普通 ":"
if seg_len == 0 then return false end
segs = segs + 1
seg_len = 0
i = i + 1
end
else
-- hex 数字
local is_hex =
(b >= 48 and b <= 57) or -- 0-9
(b >= 65 and b <= 70) or -- A-F
(b >= 97 and b <= 102) -- a-f
if not is_hex then return false end
seg_len = seg_len + 1
if seg_len > 4 then return false end
i = i + 1
end
end
if seg_len > 0 then segs = segs + 1 end
if not saw_dc then return segs == 8 end
return segs <= 8
end
-- IPv6 cidr检测,替代 string.find ":-[%x]+%:+[%x]-[%/][%d]+$"
local function is_ipv6_cidr(s)
return is_ipv6(s, true)
end
-- 检测是否含有冒号,替代 string.find(line, ":")
local function has_colon(s)
for i = 1, #s do
if s:byte(i) == 58 then -- ':'
return true
end
end
return false
end
-- 域名提取,替代 string.match "([%w%-]+%.[%w%.%-]+)[%/%*]*"
local function extract_domain(s)
if not s or s == "" then return nil end
local len = #s
local start = nil
local last_dot = nil
for i = 1, len do
local b = s:byte(i)
-- 允许的域名字符:a-zA-Z0-9.-
if (b >= 48 and b <= 57) or (b >= 65 and b <= 90) or (b >= 97 and b <= 122) or b == 45 or b == 46 then
if not start then start = i end
if b == 46 then last_dot = i end
else
if start then
if last_dot and last_dot > start then
local domain = s:sub(start, i - 1)
while domain:byte(1) == 46 do
domain = domain:sub(2)
end
return domain
else
start = nil
last_dot = nil
end
end
end
end
if start and last_dot and last_dot > start then
local domain = s:sub(start)
while domain:byte(1) == 46 do
domain = domain:sub(2)
end
return domain
end
return nil
end
local function non_file_check(file_path, header_content)
local remote_file_size = nil
local local_file_size = tonumber(fs.stat(file_path, "size") or 0)
if local_file_size == 0 then
log("下载文件为空或读取出错。")
return true
end
if header_content and header_content ~= "" then
for size in header_content:gmatch("[Cc]ontent%-[Ll]ength:%s*(%d+)") do
local s = tonumber(size)
if s and s > 0 then
remote_file_size = s
end
end
end
if remote_file_size and remote_file_size ~= local_file_size then
log(string.format("校验出错:远程 %dB, 下载 %dB", remote_file_size, local_file_size))
return true
end
return false
end
local function GeoToRule(rule_name, rule_type, out_path)
local bin = api.finded_com("geoview")
if not (bin and api.compare_versions(api.get_app_version("geoview"), ">=", "0.1.10")) then
log("[警告] Geoview 组件缺失或版本过低,规则生成流程已被跳过。")
return false
end
local geosite_path = asset_location .. "geosite.dat"
local geoip_path = asset_location .. "geoip.dat"
local file_path = (rule_type == "domain") and geosite_path or geoip_path
local geo_arg
if rule_type == "domain" then
if rule_name == "gfwlist" then
geo_arg = "-type geosite -list gfw"
else
geo_arg = "-type geosite -list cn"
end
elseif rule_type == "ip4" then
geo_arg = "-type geoip -list cn -ipv6=false"
elseif rule_type == "ip6" then
geo_arg = "-type geoip -list cn -ipv4=false"
end
local cmd = string.format(bin .. " -input '%s' %s -lowmem=true -output '%s'", file_path, geo_arg, out_path)
sys.exec(cmd)
local local_file_size = tonumber(fs.stat(out_path, "size") or 0)
if local_file_size == 0 then
os.remove(out_path)
log(rule_name .. " 生成失败,请确保 Geo 文件正确且包含目标规则。")
return false
end
return true
end
--fetch rule
local function fetch_rule(rule_name, rule_type, url, exclude_domain, max_retries)
local sret = 0
local max_attempts = max_retries or 2
local rule_dataset = {}
local file_tmp = "/tmp/" .. rule_name .. "_tmp"
local rule_final_path = rule_path .. "/" .. rule_name
if geo2rule == "1" then
url = {"geo2rule"}
log(rule_name.. " 开始生成...")
else
log(rule_name.. " 开始更新...")
end
for k, v in ipairs(url) do
local current_file = "/tmp/" .. rule_name .. "_dl" .. k
local success = false
if v ~= "geo2rule" then
for i = 1, max_attempts do
local return_code, http_code, header = curl(v, current_file)
if return_code == 0 and not non_file_check(current_file, header) then
success = true
break
end
os.remove(current_file)
log(string.format("%s 第%d条规则下载失败 (HTTP:%s),正在进行第%d次尝试...", rule_name, k, tostring(http_code), i))
end
else
if not GeoToRule(rule_name, rule_type, current_file) then return 1 end
success = true
end
if success then
local f = io.open(current_file, "r")
if f then
if rule_name == "gfwlist" and geo2rule == "0" then
local decode = api.base64Decode(f:read("*all"))
for line in string.gmatch(decode, "[^\r\n]+") do
line = line:gsub("full:", "")
if not (is_comment_line(line) or is_ipv4(line) or has_colon(line) or (exclude_domain and check_excluded_domain(line))) then
local match = extract_domain(line)
if match and not is_ipv4(match) then
rule_dataset[match] = true
end
end
end
else
for line in f:lines() do
if rule_type == "domain" then
line = line:gsub("full:", "")
if not (is_comment_line(line) or is_ipv4(line) or has_colon(line) or (exclude_domain and check_excluded_domain(line))) then
local match = extract_domain(line)
if match and not is_ipv4(match) then
rule_dataset[match] = true
end
end
elseif rule_type == "ip4" then
local function is_0dot(s) -- "^0%..*"
return s and s:byte(1)==48 and s:byte(2)==46
end
if is_ipv4_cidr(line) and not is_0dot(line) then
rule_dataset[line] = true
end
elseif rule_type == "ip6" then
local function is_double_colon_cidr(s) -- "^::(/%d+)?$"
if not s or s:byte(1)~=58 or s:byte(2)~=58 then return false end
local l = #s
if l==2 then return true end
if l==3 or s:byte(3)~=47 then return false end
for i=4,l do
local b=s:byte(i)
if b<48 or b>57 then return false end
end
return true
end
if is_ipv6_cidr(line) and not is_double_colon_cidr(line) then
rule_dataset[line] = true
end
end
end
end
f:close()
end
else
sret = 1
log(string.format("%s 第%d条规则: %s 下载失败!", rule_name, k, v))
end
os.remove(current_file)
end
if sret == 0 then
local result_list = {}
for line, _ in pairs(rule_dataset) do table.insert(result_list, line) end
table.sort(result_list)
local out = io.open(file_tmp, "w")
if out then
for _, line in ipairs(result_list) do out:write(line .. "\n") end
out:close()
end
local old_md5 = sys.exec(string.format("md5sum %s 2>/dev/null | awk '{print $1}'", rule_final_path)):gsub("\n", "")
local new_md5 = sys.exec(string.format("md5sum %s 2>/dev/null | awk '{print $1}'", file_tmp)):gsub("\n", "")
if old_md5 ~= new_md5 then
if api.is_finded("fw4") and (rule_type == "ip4" or rule_type == "ip6") then
local nft_file = file_tmp .. ".nft"
local set_name = "psw_" .. rule_name
if rule_name == "chnroute" then set_name = "psw_chn"
elseif rule_name == "chnroute6" then set_name = "psw_chn6" end
local addr_type = (rule_type == "ip4") and "ipv4_addr" or "ipv6_addr"
gen_cache(set_name, addr_type, file_tmp, nft_file)
os.execute(string.format("mv -f %s %s.nft", nft_file, rule_final_path))
end
os.execute(string.format("mv -f %s %s", file_tmp, rule_final_path))
if not rollback then reboot = 1 end
log(string.format("%s 更新成功,总规则数 %d 条。", rule_name, #result_list))
else
log(rule_name .. " 版本一致,无需更新。")
os.remove(file_tmp)
end
else
log(rule_name .. " 更新失败(部分或全部资源无法下载)。")
os.remove(file_tmp)
end
return 0
end
local function fetch_geofile(geo_name, geo_type, url)
local tmp_path = "/tmp/" .. geo_name
local asset_path = asset_location .. geo_name
local down_filename = url:match("^.*/([^/?#]+)")
local sha_url = url:gsub(down_filename, down_filename .. ".sha256sum")
local sha_path = tmp_path .. ".sha256sum"
local function verify_sha256(sha_file)
return sys.call("sha256sum -c " .. sha_file .. " > /dev/null 2>&1") == 0
end
local sha_verify = select(1, curl(sha_url, sha_path)) == 0
if sha_verify then
local f = io.open(sha_path, "r")
if f then
local content = f:read("*l")
f:close()
if content then
content = content:gsub("(%x+)%s+.+", "%1 " .. tmp_path)
f = io.open(sha_path, "w")
if f then
f:write(content)
f:close()
end
end
end
if fs.access(asset_path) then
sys.call(string.format("cp -f %s %s", asset_path, tmp_path))
if verify_sha256(sha_path) then
log(geo_type .. " 版本一致,无需更新。")
return 0
end
end
end
local sret_tmp, _, header = curl(url, tmp_path)
if sret_tmp == 0 and non_file_check(tmp_path, header) then
log(geo_type .. " 下载文件过程出错,尝试重新下载。")
os.remove(tmp_path)
sret_tmp, _, header= curl(url, tmp_path)
if sret_tmp == 0 and non_file_check(tmp_path, header) then
sret_tmp = 1
log(geo_type .. " 下载文件过程出错,请检查网络或下载链接后重试!")
end
end
if sret_tmp == 0 then
if sha_verify then
if verify_sha256(sha_path) then
sys.call(string.format("mkdir -p %s && mv -f %s %s", backup_path, asset_path, backup_path))
sys.call(string.format("mkdir -p %s && mv -f %s %s", asset_location, tmp_path, asset_path))
reboot = 1
log(geo_type .. " 更新成功。")
if geo_type == "geoip" then
geoip_update_ok = true
else
geosite_update_ok = true
end
else
log(geo_type .. " 更新失败,请稍后重试或更换更新URL。")
return 1
end
else
if fs.access(asset_path) and sys.call(string.format("cmp -s %s %s", tmp_path, asset_path)) == 0 then
log(geo_type .. " 版本一致,无需更新。")
return 0
end
sys.call(string.format("mkdir -p %s && mv -f %s %s", backup_path, asset_path, backup_path))
sys.call(string.format("mkdir -p %s && mv -f %s %s", asset_location, tmp_path, asset_path))
reboot = 1
log(geo_type .. " 更新成功。")
if geo_type == "geoip" then
geoip_update_ok = true
else
geosite_update_ok = true
end
end
else
log(geo_type .. " 更新失败,请稍后重试或更换更新URL。")
return 1
end
return 0
end
local function fetch_gfwlist()
fetch_rule("gfwlist","domain",gfwlist_url,true)
end
local function fetch_chnroute()
fetch_rule("chnroute","ip4",chnroute_url,false)
end
local function fetch_chnroute6()
fetch_rule("chnroute6","ip6",chnroute6_url,false)
end
local function fetch_chnlist()
fetch_rule("chnlist","domain",chnlist_url,false)
end
local function fetch_geoip()
fetch_geofile("geoip.dat","geoip",geoip_url)
end
local function fetch_geosite()
fetch_geofile("geosite.dat","geosite",geosite_url)
end
if arg2 then
string.gsub(arg2, '[^' .. "," .. ']+', function(w)
if w == "gfwlist" then
gfwlist_update = "1"
end
if w == "chnroute" then
chnroute_update = "1"
end
if w == "chnroute6" then
chnroute6_update = "1"
end
if w == "chnlist" then
chnlist_update = "1"
end
if w == "geoip" then
geoip_update = "1"
end
if w == "geosite" then
geosite_update = "1"
end
end)
if rollback then arg2 = nil end
else
gfwlist_update = uci:get(name, "@global_rules[0]", "gfwlist_update") or "1"
chnroute_update = uci:get(name, "@global_rules[0]", "chnroute_update") or "1"
chnroute6_update = uci:get(name, "@global_rules[0]", "chnroute6_update") or "1"
chnlist_update = uci:get(name, "@global_rules[0]", "chnlist_update") or "1"
geoip_update = uci:get(name, "@global_rules[0]", "geoip_update") or "1"
geosite_update = uci:get(name, "@global_rules[0]", "geosite_update") or "1"
end
if geo2rule ~= "1" and gfwlist_update == "0" and chnroute_update == "0" and chnroute6_update == "0" and chnlist_update == "0" and geoip_update == "0" and geosite_update == "0" then
os.exit(0)
end
local function check_instance(action)
local rule_lock = "/var/lock/" .. name .. "_rule_update.lock"
local sub_lock = "/var/lock/" .. name .. "_subscribe.lock"
if action == "start" then
math.randomseed(os.time() + math.floor(os.clock() * 1000))
api.nixio.nanosleep(0, math.random(100, 1000) * 1000000)
if fs.access(rule_lock) then
log("有[规则更新]实例正在运行,请稍后再试...\n")
os.exit(0)
else
luci.sys.call("touch " .. rule_lock)
end
elseif action == "end" then
luci.sys.call("rm -f " .. rule_lock)
return
end
if fs.access(sub_lock) then
log("[订阅]实例正在运行,[规则更新]进入队列等待...\n")
end
while fs.access(sub_lock) do
api.nixio.nanosleep(2, 0)
end
end
check_instance("start")
log("开始更新规则...")
local function safe_call(func, err_msg)
xpcall(func, function(e)
log(e)
log(debug.traceback())
log(err_msg)
end)
end
local function remove_tmp_geofile(name)
os.remove("/tmp/" .. name .. ".dat")
os.remove("/tmp/" .. name .. ".dat.sha256sum")
end
if geo2rule == "1" then
if geoip_update == "1" and not rollback then
log("geoip 开始更新...")
safe_call(fetch_geoip, "更新geoip发生错误...")
remove_tmp_geofile("geoip")
end
if geosite_update == "1" and not rollback then
log("geosite 开始更新...")
safe_call(fetch_geosite, "更新geosite发生错误...")
remove_tmp_geofile("geosite")
end
-- 如果是手动更新(arg2存在)始终生成规则
if arg2 then geoip_update_ok, geosite_update_ok = true, true end
chnroute_update, chnroute6_update, gfwlist_update, chnlist_update = "1", "1", "1", "1"
if geoip_update_ok then
if fs.access(asset_location .. "geoip.dat") then
safe_call(fetch_chnroute, "生成chnroute发生错误...")
safe_call(fetch_chnroute6, "生成chnroute6发生错误...")
else
log("geoip.dat 文件不存在,跳过规则生成。")
end
end
if geosite_update_ok then
if fs.access(asset_location .. "geosite.dat") then
safe_call(fetch_gfwlist, "生成gfwlist发生错误...")
safe_call(fetch_chnlist, "生成chnlist发生错误...")
else
log("geosite.dat 文件不存在,跳过规则生成。")
end
end
else
if gfwlist_update == "1" then
safe_call(fetch_gfwlist, "更新gfwlist发生错误...")
end
if chnroute_update == "1" then
safe_call(fetch_chnroute, "更新chnroute发生错误...")
end
if chnroute6_update == "1" then
safe_call(fetch_chnroute6, "更新chnroute6发生错误...")
end
if chnlist_update == "1" then
safe_call(fetch_chnlist, "更新chnlist发生错误...")
end
if geoip_update == "1" then
log("geoip 开始更新...")
safe_call(fetch_geoip, "更新geoip发生错误...")
remove_tmp_geofile("geoip")
end
if geosite_update == "1" then
log("geosite 开始更新...")
safe_call(fetch_geosite, "更新geosite发生错误...")
remove_tmp_geofile("geosite")
end
end
if not rollback then
uci:set(name, "@global_rules[0]", "gfwlist_update", gfwlist_update)
uci:set(name, "@global_rules[0]", "chnroute_update", chnroute_update)
uci:set(name, "@global_rules[0]", "chnroute6_update", chnroute6_update)
uci:set(name, "@global_rules[0]", "chnlist_update", chnlist_update)
uci:set(name, "@global_rules[0]", "geoip_update", geoip_update)
uci:set(name, "@global_rules[0]", "geosite_update", geosite_update)
api.uci_save(uci, name, true)
end
if reboot == 1 then
if arg3 == "cron" then
if not fs.access("/var/lock/" .. name .. ".lock") then
sys.call("touch /tmp/lock/" .. name .. "_cron.lock")
end
end
log("重启服务,应用新的规则。")
uci:set(name, "@global[0]", "flush_set", "1")
api.uci_save(uci, name, true, true)
end
log("规则更新完毕...\n")
check_instance("end")
Whitespace-only changes.
Loaded 100 of 759 files, more files were not shown because too many files have changed in this diff. Show more