op-packages/luci-app-xclient/root/usr/share/xclient/subscribe.lua
github-actions[bot] 32600acc60 🌴 Sync 2026-03-05 23:51:42
2026-03-05 23:51:42 +08:00

545 lines
14 KiB
Lua

#!/usr/bin/lua
require "luci.model.uci"
require "nixio"
require "luci.util"
require "luci.sys"
require "luci.jsonc"
-- these global functions are accessed all the time by the event handler
-- so caching them is worth the effort
local tinsert = table.insert
local ssub, slen, schar, sbyte, sformat, sgsub = string.sub, string.len, string.char, string.byte, string.format, string.gsub
local jsonParse, jsonStringify = luci.jsonc.parse, luci.jsonc.stringify
local b64decode = nixio.bin.b64decode
local cache = {}
local nodeResult = setmetatable({}, {__index = cache}) -- update result
local name = 'xclient'
local uciType = 'servers'
local ucic = luci.model.uci.cursor()
local proxy = '0'
local switch = '0'
local subscribe_url = ucic:get(name, 'config', 'subscribe_url')
local filter_words = '过期时间/剩余流量'
local save_words = ''
local v2_ss = "xray"
local v2_tj = "xray"
local log = function(...)
print(os.date("%Y-%m-%d %H:%M:%S ") .. table.concat({...}, " "))
end
local encrypt_methods_ss = {
"aes-128-gcm",
"aes-192-gcm",
"aes-256-gcm",
"chacha20-ietf-poly1305",
"xchacha20-ietf-poly1305"
}
local function split(full, sep)
full = full:gsub("%z", "")
local off, result = 1, {}
while true do
local nStart, nEnd = full:find(sep, off)
if not nEnd then
local res = ssub(full, off, slen(full))
if #res > 0 then
tinsert(result, res)
end
break
else
tinsert(result, ssub(full, off, nStart - 1))
off = nEnd + 1
end
end
return result
end
-- urlencode
local function get_urlencode(c)
return sformat("%%%02X", sbyte(c))
end
local function urlEncode(szText)
local str = szText:gsub("([^0-9a-zA-Z ])", get_urlencode)
str = str:gsub(" ", "+")
return str
end
local function get_urldecode(h)
return schar(tonumber(h, 16))
end
local function UrlDecode(szText)
return szText:gsub("+", " "):gsub("%%(%x%x)", get_urldecode)
end
-- trim
local function trim(text)
if not text or text == "" then
return ""
end
return (sgsub(text, "^%s*(.-)%s*$", "%1"))
end
-- md5
local function md5(content)
local stdout = luci.sys.exec('echo \"' .. urlEncode(content) .. '\" | md5sum | cut -d \" \" -f1')
-- assert(nixio.errno() == 0)
return trim(stdout)
end
-- base64
local function base64Decode(text)
local raw = text
if not text then
return ''
end
text = text:gsub("%z", "")
text = text:gsub("_", "/")
text = text:gsub("-", "+")
local mod4 = #text % 4
text = text .. string.sub('====', mod4 + 1)
local result = b64decode(text)
if result then
return result:gsub("%z", "")
else
return raw
end
end
local function checkTabValue(tab)
local revtab = {}
for k,v in pairs(tab) do
revtab[v] = true
end
return revtab
end
local function processData(szType, content)
local result = {type = szType}
if szType == 'vmess' then
local info = jsonParse(content)
result.type = 'xray'
result.protocol = 'vmess'
result.server = info.add
result.server_port = info.port
result.transport = info.net
result.vmess_id = info.id
result.alias = info.ps
-- result.mux = 1
-- result.concurrency = 8
if info.net == 'ws' then
result.ws_host = info.host
result.ws_path = info.path
end
if info.net == 'h2' then
result.h2_host = info.host
result.h2_path = info.path
end
if info.net == 'quic' then
result.quic_guise = "none"
result.quic_key = ""
result.quic_security = "none"
end
if info.net == 'grpc' then
result.serviceName = info.serviceName
end
if info.net == 'tcp' then
if info.type and info.type ~= "http" then
info.type = "none"
end
result.tcp_guise = info.type
result.http_host = info.host
result.http_path = info.path
end
if info.net == 'kcp' then
result.kcp_guise = info.type
result.mtu = 1350
result.tti = 50
result.uplink_capacity = 5
result.downlink_capacity = 20
result.read_buffer_size = 2
result.write_buffer_size = 2
end
if info.security then
result.security = info.security
end
if info.tls == "tls" or info.tls == "1" then
result.security = "tls"
result.tls_host = info.sni
result.insecure = 1
else
result.security = "none"
end
elseif szType == "ss" then
local idx_sp = 0
local alias = ""
if content:find("#") then
idx_sp = content:find("#")
alias = content:sub(idx_sp + 1, -1)
end
local info = content:sub(1, idx_sp - 1)
local hostInfo = split(base64Decode(info), "@")
local host = split(hostInfo[2], ":")
local userinfo = base64Decode(hostInfo[1])
local method = userinfo:sub(1, userinfo:find(":") - 1)
local password = userinfo:sub(userinfo:find(":") + 1, #userinfo)
result.alias = UrlDecode(alias)
result.type = v2_ss
result.password = password
result.server = host[1]
result.transport = "tcp"
result.protocol = "shadowsocks"
if host[2]:find("/%?") then
local query = split(host[2], "/%?")
result.server_port = query[1]
local params = {}
for _, v in pairs(split(query[2], '&')) do
local t = split(v, '=')
params[t[1]] = t[2]
end
if params.plugin then
local plugin_info = UrlDecode(params.plugin)
local idx_pn = plugin_info:find(";")
if idx_pn then
result.plugin = plugin_info:sub(1, idx_pn - 1)
result.plugin_opts = plugin_info:sub(idx_pn + 1, #plugin_info)
else
result.plugin = plugin_info
end
result.protocol = "shadowsocks-plugin"
end
else
result.server_port = host[2]:gsub("/","")
end
result.encrypt_method_v2ray_ss = method
elseif szType == "trojan" then
local idx_sp = 0
local alias = ""
if content:find("#") then
idx_sp = content:find("#")
alias = content:sub(idx_sp + 1, -1)
end
local info = content:sub(1, idx_sp - 1)
local hostInfo = split(info, "@")
local host = split(hostInfo[2], ":")
local userinfo = hostInfo[1]
local password = userinfo
result.alias = UrlDecode(alias)
result.type = v2_tj
result.protocol = "trojan"
result.server = host[1]
if host[2]:find("?") then
local query = split(host[2], "?")
result.server_port = query[1]
local params = {}
for _, v in pairs(split(query[2], '&')) do
local t = split(v, '=')
params[t[1]] = t[2]
end
if params.obfs == "grpc" and params.security == "none" then
result.security = "none"
else
result.security = "tls"
end
if params.sni then
result.tls_host = params.sni
end
if params.allowInsecure then
result.insecure = params.allowInsecure
end
if params.obfs then
if params.obfs == "grpc" then
result.transport = params.obfs
result.serviceName = params.serviceName
end
if params.obfs == "websocket" then
result.transport = "ws"
result.ws_host = params.obfsParam
result.ws_path = params.path
end
else
result.transport = "tcp"
end
else
result.server_port = host[2]
end
result.password = password
elseif szType == "vless" then
local idx_sp = 0
local alias = ""
if content:find("#") then
idx_sp = content:find("#")
alias = content:sub(idx_sp + 1, -1)
end
local info = content:sub(1, idx_sp - 1)
local hostInfo = split(info, "@")
local host = split(hostInfo[2], ":")
local uuid = hostInfo[1]
if host[2]:find("?") then
local query = split(host[2], "?")
local params = {}
for _, v in pairs(split(UrlDecode(query[2]), '&')) do
local t = split(v, '=')
params[t[1]] = t[2]
end
result.alias = UrlDecode(alias)
result.type = 'v2ray'
result.protocol = 'vless'
result.server = host[1]
result.server_port = query[1]
result.vmess_id = uuid
result.vless_encryption = params.encryption or "none"
result.transport = params.type and (params.type == 'http' and 'h2' or params.type) or "tcp"
if not params.type or params.type == "tcp" then
result.vless_flow = params.flow
end
if params.type == 'ws' then
result.ws_host = params.host
result.ws_path = params.path or "/"
end
if params.type == 'http' then
result.h2_host = params.host
result.h2_path = params.path or "/"
end
if params.type == 'kcp' then
result.kcp_guise = params.headerType or "none"
result.mtu = 1350
result.tti = 50
result.uplink_capacity = 5
result.downlink_capacity = 20
result.read_buffer_size = 2
result.write_buffer_size = 2
result.seed = params.seed or ""
end
if info.net == 'quic' then
result.quic_guise = "none"
result.quic_key = ""
result.quic_security = "none"
end
if params.type == 'grpc' then
result.serviceName = params.serviceName
end
if params.security == "tls" then
result.security = "tls"
result.tls_host = params.sni
elseif params.security == "reality" then
result.security = "reality"
result.tls_host = params.sni
result.publicKey = params.publicKey
result.shortId = params.shortId
result.fingerprint = params.fingerprint
result.spiderX = params.spiderX
else
result.security = "none"
end
else
result.server_port = host[2]
end
end
if not result.alias then
if result.server and result.server_port then
result.alias = result.server .. ':' .. result.server_port
else
result.alias = "NULL"
end
end
local alias = result.alias
result.alias = nil
result.hashkey = md5(jsonStringify(result))
result.alias = alias
return result
end
-- wget
local function wget(url)
local stdout = luci.sys.exec('uclient-fetch -q --user-agent="Luci-app-xclient/OpenWRT" --no-check-certificate -O- "' .. url .. '"')
return trim(stdout)
end
local function check_filer(result)
do
local filter_word = split(filter_words, "/")
local check_save = false
if save_words ~= nil and save_words ~= "" and save_words ~= "NULL" then
check_save = true
end
local save_word = split(save_words, "/")
local filter_result = false
local save_result = true
for i, v in pairs(filter_word) do
if tostring(result.alias):find(v) then
filter_result = true
end
end
if check_save == true then
for i, v in pairs(save_word) do
if tostring(result.alias):find(v) then
save_result = false
end
end
else
save_result = false
end
if filter_result == true or save_result == true then
return true
else
return false
end
end
end
local execute = function()
-- exec
do
if proxy == '0' then
log('service is pausing..')
luci.sys.init.stop(name)
end
if subscribe_url then
local raw = wget(subscribe_url)
if #raw > 0 then
local nodes, szType
local groupHash = md5(subscribe_url)
cache[groupHash] = {}
tinsert(nodeResult, {})
local index = #nodeResult
if jsonParse(raw) then
nodes = jsonParse(raw).servers or jsonParse(raw)
if nodes[1].server and nodes[1].method then
szType = 'sip008'
end
else
nodes = split(base64Decode(raw):gsub(" ", "_"), "\n")
end
for _, v in ipairs(nodes) do
if v then
local result
if szType then
result = processData(szType, v)
elseif not szType then
local node = trim(v)
local dat = split(node, "://")
if dat and dat[1] and dat[2] then
local dat3 = ""
if dat[3] then
dat3 = "://" .. dat[3]
end
if dat[1] == 'ss' or dat[1] == 'trojan' then
result = processData(dat[1], dat[2] .. dat3)
else
result = processData(dat[1], base64Decode(dat[2]))
end
end
else
log('skip unknown types: ' .. szType)
end
-- log(result)
if result then
if not result.server or not result.server_port or result.alias == "NULL" or check_filer(result) or result.server:match("[^0-9a-zA-Z%-%.%s]") or cache[groupHash][result.hashkey] then
log('Discard invalid nodes: 【' .. result.type .. ' 】, ' .. result.alias)
else
result.grouphashkey = groupHash
tinsert(nodeResult[index], result)
cache[groupHash][result.hashkey] = nodeResult[index][#nodeResult[index]]
end
end
end
end
log('Number of nodes successfully resolved: ' .. #nodes)
else
log(subscribe_url .. ': get content is empty')
end
end
end
-- diff
do
if next(nodeResult) == nil then
log("Update failed, no node info available")
return
end
local add, del = 0, 0
ucic:foreach(name, uciType, function(old)
if old.grouphashkey or old.hashkey then
if not nodeResult[old.grouphashkey] or not nodeResult[old.grouphashkey][old.hashkey] then
ucic:delete(name, old['.name'])
del = del + 1
else
local dat = nodeResult[old.grouphashkey][old.hashkey]
ucic:tset(name, old['.name'], dat)
setmetatable(nodeResult[old.grouphashkey][old.hashkey], {__index = {_ignore = true}})
end
else
if not old.alias then
if old.server or old.server_port then
old.alias = old.server .. ':' .. old.server_port
log('Ignore manually added nodes: ' .. old.alias)
else
ucic:delete(name, old['.name'])
end
else
log('Ignore manually added nodes: ' .. old.alias)
end
end
end)
for k, v in ipairs(nodeResult) do
for kk, vv in ipairs(v) do
if not vv._ignore then
local section = ucic:add(name, uciType)
ucic:tset(name, section, vv)
add = add + 1
end
end
end
ucic:commit(name)
local globalServer = ucic:get_first(name, 'global', 'global_server', '')
if globalServer ~= "nil" then
local firstServer = ucic:get_first(name, uciType)
if firstServer then
if not ucic:get(name, globalServer) then
luci.sys.call("/etc/init.d/" .. name .. " stop > /dev/null 2>&1 &")
ucic:commit(name)
ucic:set(name, ucic:get_first(name, 'global'), 'global_server', ucic:get_first(name, uciType))
ucic:commit(name)
log('The current active server has been deleted and is being automatically replaced with the first node。')
luci.sys.call("/etc/init.d/" .. name .. " start > /dev/null 2>&1 &")
else
log('Maintain the current servers。')
luci.sys.call("/etc/init.d/" .. name .. " boot > /dev/null 2>&1 &")
end
else
log('There is no server node, stop the service')
luci.sys.call("/etc/init.d/" .. name .. " stop > /dev/null 2>&1 &")
end
end
log('New Nodes: ' .. add, 'Deleted Nodes: ' .. del)
log('Subscription updated successfully')
end
end
if subscribe_url and #subscribe_url > 0 then
xpcall(execute, function(e)
log(e)
log(debug.traceback())
log('An error occurred, restoring service')
local firstServer = ucic:get_first(name, uciType)
if firstServer then
luci.sys.call("/etc/init.d/" .. name .. " boot > /dev/null 2>&1 &")
log('Service Restart Successful')
else
luci.sys.call("/etc/init.d/" .. name .. " stop > /dev/null 2>&1 &")
log('Service Stop Successful')
end
end)
end