mirror of
https://github.com/caiwx86/small-packages.git
synced 2026-07-30 02:31:47 +08:00
2252 lines
66 KiB
Lua
Executable File
2252 lines
66 KiB
Lua
Executable File
#!/usr/bin/lua
|
||
|
||
------------------------------------------------
|
||
-- This file is part of the luci-app-ssr-plus subscribe.lua
|
||
-- @author William Chan <root@williamchan.me>
|
||
------------------------------------------------
|
||
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 b64encode = nixio.bin.b64encode
|
||
local URL = require "url"
|
||
local cache = {}
|
||
local nodeResult = setmetatable({}, {__index = cache}) -- update result
|
||
local name = 'shadowsocksr'
|
||
local uciType = 'servers'
|
||
local ucic = require "luci.model.uci".cursor()
|
||
local proxy = ucic:get_first(name, 'server_subscribe', 'proxy', '0')
|
||
local switch = ucic:get_first(name, 'server_subscribe', 'switch', '1')
|
||
local allow_insecure = ucic:get_first(name, 'server_subscribe', 'allow_insecure', '0')
|
||
local filter_words = ucic:get_first(name, 'server_subscribe', 'filter_words', '过期/套餐/剩余/网址/QQ群/官网/防失联/回国')
|
||
local save_words = ucic:get_first(name, 'server_subscribe', 'save_words', '')
|
||
local user_agent = ucic:get_first(name, 'server_subscribe', 'user_agent', 'v2rayN/9.99')
|
||
local local_clash_dir = "/etc/ssrplus/clash"
|
||
local target_subscribe_sid = tostring(arg and arg[1] or ""):gsub("^%s*(.-)%s*$", "%1")
|
||
|
||
local has_ss_rust = luci.sys.exec('type -t -p sslocal 2>/dev/null || type -t -p ssserver 2>/dev/null') ~= ""
|
||
local has_xray = luci.sys.exec('type -t -p xray 2>/dev/null') ~= ""
|
||
local has_mihomo = luci.sys.exec('type -t -p mihomo -p /usr/libexec/mihomo 2>/dev/null') ~= ""
|
||
|
||
local tuic_type = luci.sys.exec('type -t -p mihomo -p /usr/libexec/mihomo 2>/dev/null') ~= "" and "tuic"
|
||
local log = function(...)
|
||
print(os.date("%Y-%m-%d %H:%M:%S ") .. table.concat({...}, " "))
|
||
end
|
||
|
||
local function preferred_ss_backend()
|
||
if has_mihomo then
|
||
return "ss"
|
||
end
|
||
if has_ss_rust then
|
||
return "ss-rust"
|
||
end
|
||
if has_xray then
|
||
return "v2ray"
|
||
end
|
||
return nil
|
||
end
|
||
|
||
local encrypt_methods_ss = {
|
||
-- plain
|
||
"none",
|
||
"plain",
|
||
-- aead
|
||
"aes-128-gcm",
|
||
"aes-192-gcm",
|
||
"aes-256-gcm",
|
||
"chacha20-ietf-poly1305",
|
||
"xchacha20-ietf-poly1305",
|
||
-- aead 2022
|
||
"2022-blake3-aes-128-gcm",
|
||
"2022-blake3-aes-256-gcm",
|
||
"2022-blake3-chacha20-poly1305"
|
||
--[[ stream
|
||
"table",
|
||
"rc4",
|
||
"rc4-md5",
|
||
"aes-128-cfb",
|
||
"aes-192-cfb",
|
||
"aes-256-cfb",
|
||
"aes-128-ctr",
|
||
"aes-192-ctr",
|
||
"aes-256-ctr",
|
||
"bf-cfb",
|
||
"camellia-128-cfb",
|
||
"camellia-192-cfb",
|
||
"camellia-256-cfb",
|
||
"salsa20",
|
||
"chacha20",
|
||
"chacha20-ietf" ]]--
|
||
}
|
||
-- 分割字符串
|
||
local function split(full, sep)
|
||
if full == nil or type(full) ~= "string" then
|
||
-- print("Debug: split() received nil or non-string value")
|
||
return {}
|
||
end
|
||
full = full:gsub("%z", ""):gsub("^%s+", ""):gsub("%s+$", "") -- 去除首尾空白字符和\0
|
||
if full == "" then
|
||
-- print("Debug: split() received empty string after trimming")
|
||
return {}
|
||
end
|
||
sep = sep or "," -- 默认分隔符
|
||
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 -- 过滤掉 \0
|
||
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
|
||
|
||
local function shell_quote(value)
|
||
value = tostring(value or "")
|
||
return "'" .. value:gsub("'", "'\\''") .. "'"
|
||
end
|
||
|
||
local function nft_string_literal(value)
|
||
value = tostring(value or "")
|
||
value = value:gsub("\\", "\\\\"):gsub('"', '\\"')
|
||
return '"' .. value .. '"'
|
||
end
|
||
|
||
local function escape_lua_pattern(value)
|
||
return tostring(value or ""):gsub("([%(%)%.%%%+%-%*%?%[%]%^%$])", "%%%1")
|
||
end
|
||
|
||
local function is_true_value(v)
|
||
if v == nil then
|
||
return false
|
||
end
|
||
if v == true then
|
||
return true
|
||
end
|
||
if type(v) == "string" then
|
||
local s = trim(string.lower(v))
|
||
return s == "1" or s == "true" or s == "yes" or s == "on"
|
||
end
|
||
return false
|
||
end
|
||
|
||
local function collect_subscribe_items()
|
||
local items = {}
|
||
|
||
ucic:foreach(name, "server_subscribe_item", function(s)
|
||
if target_subscribe_sid ~= "" and s[".name"] ~= target_subscribe_sid then
|
||
return
|
||
end
|
||
|
||
local url = trim(s.url or "")
|
||
if url == "" then
|
||
return
|
||
end
|
||
|
||
if target_subscribe_sid == "" and not is_true_value(s.enabled or "1") then
|
||
return
|
||
end
|
||
|
||
items[#items + 1] = {
|
||
sid = s[".name"],
|
||
alias = trim(s.alias or ""),
|
||
url = url
|
||
}
|
||
end)
|
||
|
||
if #items > 0 then
|
||
return items
|
||
end
|
||
|
||
if target_subscribe_sid ~= "" then
|
||
return items
|
||
end
|
||
|
||
local legacy_urls = ucic:get_first(name, 'server_subscribe', 'subscribe_url', {})
|
||
for index, url in ipairs(legacy_urls or {}) do
|
||
url = trim(url)
|
||
if url ~= "" then
|
||
items[#items + 1] = {
|
||
sid = "legacy_" .. index,
|
||
alias = "Legacy " .. index,
|
||
url = url
|
||
}
|
||
end
|
||
end
|
||
|
||
return items
|
||
end
|
||
|
||
local subscribe_items = collect_subscribe_items()
|
||
|
||
local function first_nonempty(tbl, keys)
|
||
for _, key in ipairs(keys) do
|
||
local value = tbl[key]
|
||
if value ~= nil and value ~= "" then
|
||
return value
|
||
end
|
||
end
|
||
return nil
|
||
end
|
||
|
||
local function normalize_host(value)
|
||
value = trim(value or "")
|
||
if value == "" then
|
||
return value
|
||
end
|
||
if value:match("^%[.*%]$") then
|
||
return value:sub(2, -2)
|
||
end
|
||
return value
|
||
end
|
||
|
||
local function parse_host_port(value, default_port)
|
||
value = trim(value or "")
|
||
if value == "" then
|
||
return nil, default_port
|
||
end
|
||
|
||
local host, port = value:match("^%[(.*)%]:(%d+)$")
|
||
if host then
|
||
return normalize_host(host), port
|
||
end
|
||
|
||
host, port = value:match("^(.-):(%d+)$")
|
||
if host and host ~= "" and not host:find(":", 1, true) then
|
||
return normalize_host(host), port
|
||
end
|
||
|
||
if value:find(":", 1, true) then
|
||
return normalize_host(value), default_port
|
||
end
|
||
|
||
return normalize_host(value), default_port
|
||
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 or text == "" then
|
||
return ''
|
||
end
|
||
text = text:gsub("%z", "")
|
||
text = text:gsub("%c", "")
|
||
text = text:gsub("%s", "")
|
||
text = text:gsub("_", "/")
|
||
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
|
||
-- base64 编码
|
||
local function base64Encode(text)
|
||
if not text or text == "" then
|
||
return ''
|
||
end
|
||
local result = b64encode(text)
|
||
if result then
|
||
result = result:gsub("%z", "")
|
||
return result
|
||
else
|
||
return text
|
||
end
|
||
end
|
||
-- 检查数组(table)中是否存在某个字符值
|
||
-- https://www.04007.cn/article/135.html
|
||
local function checkTabValue(tab)
|
||
local revtab = {}
|
||
for k,v in pairs(tab) do
|
||
revtab[v] = true
|
||
end
|
||
return revtab
|
||
end
|
||
-- JSON完整性检查
|
||
local function isCompleteJSON(str)
|
||
-- 检查JSON格式
|
||
if type(str) ~= "string" or str:match("^%s*$") then
|
||
return false
|
||
end
|
||
-- 尝试解析JSON验证完整性
|
||
local success, _ = pcall(jsonParse, str)
|
||
return success
|
||
end
|
||
|
||
local function isClashYAML(str)
|
||
if type(str) ~= "string" or str:match("^%s*$") then
|
||
return false
|
||
end
|
||
|
||
for line in str:gmatch("[^\r\n]+") do
|
||
if line:match("^%s*proxies%s*:") or line:match("^%s*proxy%-providers%s*:") then
|
||
return true
|
||
end
|
||
end
|
||
|
||
return false
|
||
end
|
||
|
||
local function processClashSubscription(url)
|
||
local ok, parsed = pcall(URL.parse, url)
|
||
if not ok or not parsed or not parsed.host then
|
||
return nil
|
||
end
|
||
|
||
local alias = "Clash_" .. parsed.host
|
||
local server_port = parsed.port or ((parsed.scheme == "http") and "80" or "443")
|
||
local result = {
|
||
type = "clash",
|
||
server = normalize_host(parsed.host),
|
||
server_port = server_port,
|
||
clash_url = url,
|
||
clash_user_agent = user_agent,
|
||
raw_alias = alias,
|
||
alias = alias
|
||
}
|
||
|
||
local saved_alias = result.alias
|
||
result.alias = nil
|
||
result.hashkey = md5(jsonStringify(result) .. "_" .. (saved_alias or ""))
|
||
result.alias = saved_alias
|
||
return result
|
||
end
|
||
|
||
local function yaml_quote(str)
|
||
str = tostring(str or "")
|
||
str = str:gsub("\\", "\\\\"):gsub('"', '\\"')
|
||
return '"' .. str .. '"'
|
||
end
|
||
|
||
local function is_ip_literal(value)
|
||
if not value or value == "" then
|
||
return false
|
||
end
|
||
|
||
if value:match("^%d+%.%d+%.%d+%.%d+$") then
|
||
return true
|
||
end
|
||
|
||
return value:find(":", 1, true) ~= nil
|
||
end
|
||
|
||
local function parseAnytlsShare(content)
|
||
local alias = ""
|
||
if content:find("#", 1, true) then
|
||
local idx = content:find("#", 1, true)
|
||
alias = UrlDecode(content:sub(idx + 1))
|
||
content = content:sub(1, idx - 1)
|
||
end
|
||
|
||
local main, query = content, ""
|
||
if content:find("%?", 1) then
|
||
local idx = content:find("%?", 1)
|
||
main = content:sub(1, idx - 1)
|
||
query = content:sub(idx + 1)
|
||
end
|
||
|
||
local userinfo, hostinfo = main:match("^([^@]+)@(.+)$")
|
||
if not userinfo or not hostinfo then
|
||
return nil
|
||
end
|
||
|
||
local password = UrlDecode(userinfo)
|
||
local server, port = hostinfo:match("^(.+):(%d+)$")
|
||
if not server or not port then
|
||
server = hostinfo
|
||
port = "443"
|
||
end
|
||
server = server:gsub("^%[", ""):gsub("%]$", "")
|
||
|
||
local params = {}
|
||
for _, v in ipairs(split(query, "&")) do
|
||
local t = split(v, "=")
|
||
if #t > 1 then
|
||
params[string.lower(t[1])] = UrlDecode(t[2] or "")
|
||
end
|
||
end
|
||
|
||
return {
|
||
name = (alias ~= "" and alias) or (server .. ":" .. port),
|
||
server = server,
|
||
port = tonumber(port),
|
||
password = password,
|
||
sni = (function()
|
||
local sni = params.sni or params.servername or ""
|
||
if is_ip_literal(sni) then
|
||
return ""
|
||
end
|
||
return sni
|
||
end)(),
|
||
allow_insecure = (params.insecure == "1" or params.allow_insecure == "1" or params.allowinsecure == "1"),
|
||
client_fingerprint = params.fp or params.fingerprint or ""
|
||
}
|
||
end
|
||
|
||
local function buildAnytlsClashYaml(entries, group_name)
|
||
local lines = {
|
||
"mode: rule",
|
||
"log-level: silent",
|
||
"proxies:"
|
||
}
|
||
|
||
for _, node in ipairs(entries) do
|
||
lines[#lines + 1] = " - name: " .. yaml_quote(node.name)
|
||
lines[#lines + 1] = " type: anytls"
|
||
lines[#lines + 1] = " server: " .. yaml_quote(node.server)
|
||
lines[#lines + 1] = " port: " .. tostring(node.port)
|
||
lines[#lines + 1] = " password: " .. yaml_quote(node.password)
|
||
if node.sni and node.sni ~= "" then
|
||
lines[#lines + 1] = " sni: " .. yaml_quote(node.sni)
|
||
end
|
||
if node.allow_insecure then
|
||
lines[#lines + 1] = " skip-cert-verify: true"
|
||
end
|
||
if node.client_fingerprint and node.client_fingerprint ~= "" then
|
||
lines[#lines + 1] = " client-fingerprint: " .. yaml_quote(node.client_fingerprint)
|
||
end
|
||
end
|
||
|
||
lines[#lines + 1] = "proxy-groups:"
|
||
lines[#lines + 1] = " - name: " .. yaml_quote(group_name)
|
||
lines[#lines + 1] = " type: select"
|
||
lines[#lines + 1] = " proxies:"
|
||
for _, node in ipairs(entries) do
|
||
lines[#lines + 1] = " - " .. yaml_quote(node.name)
|
||
end
|
||
lines[#lines + 1] = "rules:"
|
||
lines[#lines + 1] = " - MATCH," .. group_name
|
||
|
||
return table.concat(lines, "\n") .. "\n"
|
||
end
|
||
|
||
local function processLocalClashSubscription(path, alias)
|
||
local result = {
|
||
type = "clash",
|
||
server = "127.0.0.1",
|
||
server_port = "0",
|
||
clash_path = path,
|
||
clash_user_agent = user_agent,
|
||
raw_alias = alias,
|
||
alias = alias
|
||
}
|
||
|
||
local saved_alias = result.alias
|
||
result.alias = nil
|
||
result.hashkey = md5(jsonStringify(result) .. "_" .. (saved_alias or ""))
|
||
result.alias = saved_alias
|
||
return result
|
||
end
|
||
-- 处理数据
|
||
local function processData(szType, content, cfgid)
|
||
local result = {type = szType, kcp_param = '--nocomp'}
|
||
-- 检查JSON的格式如不完整丢弃
|
||
if not (szType == "sip008" or szType == "ssd") then
|
||
if not isCompleteJSON(content) then
|
||
return nil
|
||
end
|
||
end
|
||
|
||
if szType == "hysteria2" or szType == "hy2" then
|
||
local url = URL.parse("http://" .. content)
|
||
local params = url.query
|
||
|
||
if not has_xray then
|
||
return nil
|
||
end
|
||
|
||
result.type = "v2ray"
|
||
result.v2ray_protocol = "hysteria2"
|
||
if params.fm and params.fm ~= "" then
|
||
result.enable_finalmask = "1"
|
||
result.finalmask = base64Encode(params.fm)
|
||
end
|
||
if (params.security and params.security:lower() == "tls")
|
||
or (params.sni and params.sni ~= "")
|
||
or (params.alpn and params.alpn ~= "")
|
||
or (params.pcs or params.vcn) then
|
||
result.tls = "1"
|
||
if params.sni then
|
||
result.tls_host = params.sni
|
||
end
|
||
if params.alpn and params.alpn ~= "" then
|
||
local alpn = {}
|
||
for v in params.alpn:gmatch("[^,;|%s]+") do
|
||
table.insert(alpn, v)
|
||
end
|
||
if #alpn > 0 then
|
||
result.tls_alpn = table.concat(alpn, ",")
|
||
end
|
||
end
|
||
if params.pcs then
|
||
result.tls_CertSha = params.pcs
|
||
end
|
||
if params.vcn then
|
||
result.tls_CertByName = params.vcn
|
||
end
|
||
end
|
||
|
||
local raw_alias = url.fragment and UrlDecode(url.fragment) or nil
|
||
result.raw_alias = raw_alias -- 新增
|
||
result.alias = raw_alias -- 临时赋值(后面会被覆盖)
|
||
result.server = normalize_host(url.host)
|
||
result.server_port = url.port or 443
|
||
result.hy2_auth = url.user
|
||
|
||
if params.mport then
|
||
result.flag_port_hopping = "1"
|
||
result.port_range = params.mport
|
||
end
|
||
result.uplink_capacity = tonumber((params.upmbps or ""):match("^(%d+)")) or nil
|
||
result.downlink_capacity = tonumber((params.downmbps or ""):match("^(%d+)")) or nil
|
||
if params.obfs and params.obfs ~= "none" then
|
||
result.flag_obfs = "1"
|
||
result.obfs_type = params.obfs
|
||
result.salamander = params["obfs-password"] or params["obfs_password"]
|
||
end
|
||
if params.allowInsecure or params.insecure then
|
||
local insecure = params.allowInsecure or params.insecure
|
||
if insecure == true or insecure == "1" or insecure == "true" then
|
||
result.insecure = "1"
|
||
end
|
||
end
|
||
if params.tfo then
|
||
-- 处理 fast open 参数
|
||
result.fast_open = params.tfo
|
||
end
|
||
elseif szType == 'ssr' then
|
||
-- 去掉前后空白和#注释
|
||
local link = trim(content:gsub("#.*$", ""))
|
||
local dat = split(link, "/%?")
|
||
local host, port, rest
|
||
local hostinfo = dat[1] or ''
|
||
if hostinfo:find("^%[.*%]:") then
|
||
host, port, rest = hostinfo:match("^%[(.*)%]:(%d+):(.*)$")
|
||
else
|
||
host, port, rest = hostinfo:match("^(.-):(%d+):(.*)$")
|
||
end
|
||
|
||
result.type = 'ssr'
|
||
local ssr_parts = split(rest or '', ':')
|
||
result.server = normalize_host(host or '')
|
||
result.server_port = port or ''
|
||
result.protocol = ssr_parts[1] or ''
|
||
result.encrypt_method = ssr_parts[2] or ''
|
||
result.obfs = ssr_parts[3] or ''
|
||
result.password = base64Decode(ssr_parts[4] or '')
|
||
|
||
local params = {}
|
||
if dat[2] and dat[2] ~= '' then
|
||
for _, v in pairs(split(dat[2], '&')) do
|
||
local t = split(v, '=')
|
||
if t[1] and t[2] then
|
||
params[t[1]] = t[2]
|
||
end
|
||
end
|
||
end
|
||
|
||
result.obfs_param = base64Decode(params.obfsparam or '')
|
||
result.protocol_param = base64Decode(params.protoparam or '')
|
||
|
||
if params.tfo then
|
||
-- 处理 fast open 参数
|
||
result.fast_open = params.tfo
|
||
end
|
||
|
||
local group = base64Decode(params.group or '')
|
||
local remarks = base64Decode(params.remarks or '')
|
||
|
||
-- 拼接 alias
|
||
local alias = ""
|
||
if group ~= "" then
|
||
raw_alias = "[" .. group .. "] "
|
||
end
|
||
raw_alias = raw_alias .. remarks
|
||
result.raw_alias = raw_alias -- 新增
|
||
result.alias = raw_alias -- 临时赋值(后面会被覆盖)
|
||
elseif szType == "vmess" then
|
||
-- 去掉前后空白和注释
|
||
local link = trim(content:gsub("#.*$", ""))
|
||
|
||
-- Base64 解码
|
||
local decoded = base64Decode(link)
|
||
if not decoded or decoded == "" then
|
||
return nil
|
||
end
|
||
|
||
-- 解析 JSON
|
||
local ok, info = pcall(jsonParse, decoded)
|
||
if not ok or type(info) ~= "table" then
|
||
return nil
|
||
end
|
||
|
||
-- 基本信息
|
||
result.type = 'v2ray'
|
||
result.v2ray_protocol = 'vmess'
|
||
result.server = info.add
|
||
result.server_port = info.port
|
||
result.alter_id = info.aid
|
||
result.vmess_id = info.id
|
||
result.raw_alias = info.ps -- 新增
|
||
result.alias = info.ps -- 临时赋值(后面会被覆盖)
|
||
|
||
-- 调整传输协议
|
||
if info.net == "tcp" then
|
||
info.net = "raw"
|
||
end
|
||
if info.net == "splithttp" then
|
||
info.net = "xhttp"
|
||
end
|
||
result.transport = info.net
|
||
|
||
-- 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 == 'httpupgrade' then
|
||
result.httpupgrade_host = info.host
|
||
result.httpupgrade_path = info.path
|
||
end
|
||
if info.net == 'xhttp' or info.net == 'splithttp' then
|
||
result.xhttp_mode = info.mode
|
||
result.xhttp_host = info.host
|
||
result.xhttp_path = info.path
|
||
-- 检查 extra 参数是否存在且非空
|
||
if info.extra and info.extra ~= "" then
|
||
result.enable_xhttp_extra = "1"
|
||
result.xhttp_extra = base64Encode(info.extra)
|
||
end
|
||
-- 尝试解析 JSON 数据
|
||
local success, Data = pcall(jsonParse, info.extra or "")
|
||
if success and type(Data) == "table" then
|
||
local address = (Data.extra and Data.extra.downloadSettings and Data.extra.downloadSettings.address)
|
||
or (Data.downloadSettings and Data.downloadSettings.address)
|
||
result.download_address = (address and address ~= "") and address:gsub("^%[", ""):gsub("%]$", "")
|
||
else
|
||
-- 如果解析失败,清空下载地址
|
||
result.download_address = nil
|
||
end
|
||
end
|
||
if info.net == 'h2' then
|
||
result.h2_host = info.host
|
||
result.h2_path = info.path
|
||
end
|
||
if info.net == 'raw' or info.net == 'tcp' then
|
||
result.tcp_guise = info.type or "none"
|
||
if result.tcp_guise == "http" then
|
||
result.http_host = info.host
|
||
result.http_path = info.path
|
||
end
|
||
end
|
||
if info.net == 'kcp' then
|
||
result.kcp_guise = info.type or "none"
|
||
if info.type and info.type == "dns" then
|
||
result.kcp_guise = info.host or ""
|
||
end
|
||
result.seed = params.seed
|
||
end
|
||
if info.net == 'grpc' then
|
||
if info.path then
|
||
result.serviceName = info.path
|
||
elseif info.serviceName then
|
||
result.serviceName = info.serviceName
|
||
end
|
||
end
|
||
if info.net == 'quic' then
|
||
result.quic_guise = info.type
|
||
result.quic_key = info.key
|
||
result.quic_security = info.security
|
||
end
|
||
if info.security then
|
||
result.security = info.security
|
||
end
|
||
if info.fm and info.fm ~= "" then
|
||
info.fm = UrlDecode(info.fm)
|
||
result.enable_finalmask = "1"
|
||
result.finalmask = base64Encode(info.fm)
|
||
end
|
||
if info.tls == "tls" or info.tls == "1" then
|
||
result.tls = "1"
|
||
result.fingerprint = info.fp
|
||
if info.alpn and info.alpn ~= "" then
|
||
local alpn = {}
|
||
for v in info.alpn:gmatch("[^,]+") do
|
||
table.insert(alpn, v)
|
||
end
|
||
if #alpn > 0 then
|
||
result.tls_alpn = table.concat(alpn, ",") -- 确保为字符串
|
||
end
|
||
end
|
||
if info.sni and info.sni ~= "" then
|
||
result.tls_host = info.sni
|
||
elseif info.host and info.host ~= "" then
|
||
result.tls_host = info.host
|
||
end
|
||
if info.ech and info.ech ~= "" then
|
||
result.enable_ech = "1"
|
||
result.ech_config = info.ech
|
||
end
|
||
if info.pcs and info.pcs ~= "" then
|
||
result.tls_CertSha = info.pcs
|
||
end
|
||
if info.vcn and info.vcn ~= "" then
|
||
result.tls_CertByName = info.vcn
|
||
end
|
||
-- 兼容 allowInsecure / allowlnsecure / skip-cert-verify
|
||
if info.allowInsecure or info.allowlnsecure or info.insecure or info["skip-cert-verify"] then
|
||
local insecure = info.allowInsecure or info.allowlnsecure or info.insecure or info["skip-cert-verify"]
|
||
if insecure == true or insecure == "1" or insecure == "true" then
|
||
result.insecure = "1"
|
||
end
|
||
end
|
||
else
|
||
result.tls = "0"
|
||
end
|
||
elseif szType == "ss" then
|
||
local idx_sp = content:find("#") or 0
|
||
local alias = ""
|
||
if idx_sp > 0 then
|
||
alias = content:sub(idx_sp + 1, -1)
|
||
content = content:sub(0, idx_sp - 1):gsub("/%?", "?")
|
||
end
|
||
local raw_alias = UrlDecode(alias)
|
||
result.raw_alias = raw_alias -- 新增
|
||
result.alias = raw_alias -- 临时赋值(后面会被覆盖)
|
||
|
||
-- 拆 base64 主体和 ? 参数部分
|
||
local info = content
|
||
local find_index, query = info:match("^([^?]+)%??(.*)$")
|
||
--log("SS 节点格式:", find_index)
|
||
local params = {}
|
||
if query and query ~= "" then
|
||
for _, v in ipairs(split(query, '&')) do
|
||
local t = split(v, '=')
|
||
if #t >= 2 then
|
||
params[t[1]] = UrlDecode(t[2])
|
||
end
|
||
end
|
||
end
|
||
|
||
if params.tfo and params.tfo ~= "" then
|
||
-- 处理 fast open 参数
|
||
result.fast_open = params.tfo
|
||
end
|
||
|
||
local selected_ss_backend = preferred_ss_backend()
|
||
local xray_ss_mode = (selected_ss_backend == "v2ray")
|
||
|
||
-- 如果最终无可用核心,跳过该订阅
|
||
if selected_ss_backend == nil then
|
||
return nil
|
||
end
|
||
|
||
if xray_ss_mode then
|
||
local url = URL.parse("http://" .. info)
|
||
local params = url.query
|
||
|
||
result.type = "v2ray"
|
||
result.v2ray_protocol = "shadowsocks"
|
||
result.server = normalize_host(url.host)
|
||
result.server_port = url.port
|
||
|
||
-- 判断 @ 前部分是否为 Base64
|
||
local is_base64 = base64Decode(UrlDecode(url.user))
|
||
if is_base64:find(":") then
|
||
-- 新格式:method:password
|
||
result.encrypt_method_ss, result.password = is_base64:match("^(.-):(.*)$")
|
||
else
|
||
-- 旧格式:UUID 直接作为密码
|
||
result.password = url.user
|
||
result.encrypt_method_ss = params.encryption or "none"
|
||
end
|
||
|
||
if params.udp then
|
||
-- 处理 udp 参数
|
||
result.uot = params.udp
|
||
end
|
||
|
||
result.transport = params.type or "raw"
|
||
if result.transport == "tcp" then
|
||
result.transport = "raw"
|
||
end
|
||
if result.transport == "splithttp" then
|
||
result.transport = "xhttp"
|
||
end
|
||
result.tls = (params.security == "tls" or params.security == "xtls") and "1" or "0"
|
||
if params.alpn and params.alpn ~= "" then
|
||
local alpn = {}
|
||
for v in params.alpn:gmatch("[^,;|%s]+") do
|
||
table.insert(alpn, v)
|
||
end
|
||
if #alpn > 0 then
|
||
result.tls_alpn = table.concat(alpn, ",") -- 确保为字符串
|
||
end
|
||
end
|
||
if params.pcs and params.pcs ~= "" then
|
||
result.tls_CertSha = params.pcs
|
||
end
|
||
if params.vcn and params.vcn ~= "" then
|
||
result.tls_CertByName = params.vcn
|
||
end
|
||
result.tls_host = params.sni
|
||
result.tls_flow = (params.security == "tls" or params.security == "reality") and params.flow or nil
|
||
result.fingerprint = params.fp
|
||
result.reality = (params.security == "reality") and "1" or "0"
|
||
result.reality_publickey = params.pbk and UrlDecode(params.pbk) or nil
|
||
result.reality_shortid = params.sid
|
||
result.reality_spiderx = params.spx and UrlDecode(params.spx) or nil
|
||
-- 检查 ech 参数是否存在且非空
|
||
if params.ech and params.ech ~= "" then
|
||
result.enable_ech = "1"
|
||
result.ech_config = params.ech
|
||
end
|
||
-- 检查 finalmaskg 参数是否存在且非空
|
||
if params.fm and params.fm ~= "" then
|
||
result.enable_finalmask = "1"
|
||
result.finalmaskg = base64Encode(params.fm)
|
||
end
|
||
-- 检查 pqv 参数是否存在且非空
|
||
if params.pqv and params.pqv ~= "" then
|
||
result.enable_mldsa65verify = "1"
|
||
result.reality_mldsa65verify = params.pqv
|
||
end
|
||
if params.allowInsecure or params.insecure then
|
||
local insecure = params.allowInsecure or params.insecure
|
||
if insecure == true or insecure == "1" or insecure == "true" then
|
||
result.insecure = "1"
|
||
end
|
||
end
|
||
if result.transport == "ws" then
|
||
result.ws_host = (result.tls ~= "1") and (params.host and UrlDecode(params.host)) or nil
|
||
result.ws_path = params.path and UrlDecode(params.path) or "/"
|
||
elseif result.transport == "httpupgrade" then
|
||
result.httpupgrade_host = (result.tls ~= "1") and (params.host and UrlDecode(params.host)) or nil
|
||
result.httpupgrade_path = params.path and UrlDecode(params.path) or "/"
|
||
elseif result.transport == "xhttp" or result.transport == "splithttp" then
|
||
result.xhttp_mode = params.mode or "auto"
|
||
result.xhttp_host = params.host and UrlDecode(params.host) or nil
|
||
result.xhttp_path = params.path and UrlDecode(params.path) or "/"
|
||
-- 检查 extra 参数是否存在且非空
|
||
if params.extra and params.extra ~= "" then
|
||
result.enable_xhttp_extra = "1"
|
||
result.xhttp_extra = base64Encode(params.extra)
|
||
end
|
||
-- 尝试解析 JSON 数据
|
||
local success, Data = pcall(jsonParse, params.extra or "")
|
||
if success and type(Data) == "table" then
|
||
local address = (Data.extra and Data.extra.downloadSettings and Data.extra.downloadSettings.address)
|
||
or (Data.downloadSettings and Data.downloadSettings.address)
|
||
result.download_address = (address and address ~= "") and address:gsub("^%[", ""):gsub("%]$", "")
|
||
else
|
||
-- 如果解析失败,清空下载地址
|
||
result.download_address = nil
|
||
end
|
||
-- make it compatible with bullshit, "h2" transport is non-existent at all
|
||
elseif result.transport == "http" or result.transport == "h2" then
|
||
result.transport = "h2"
|
||
result.h2_host = params.host and UrlDecode(params.host) or nil
|
||
result.h2_path = params.path and UrlDecode(params.path) or nil
|
||
elseif result.transport == "kcp" then
|
||
result.kcp_guise = params.headerType or "none"
|
||
if params.headerType and params.headerType == "dns" then
|
||
result.kcp_domain = params.host or ""
|
||
end
|
||
result.seed = params.seed
|
||
elseif result.transport == "quic" then
|
||
result.quic_guise = params.headerType or "none"
|
||
result.quic_security = params.quicSecurity or "none"
|
||
result.quic_key = params.key
|
||
elseif result.transport == "grpc" then
|
||
result.serviceName = params.serviceName
|
||
result.grpc_mode = params.mode or "gun"
|
||
elseif result.transport == "tcp" or result.transport == "raw" then
|
||
result.tcp_guise = params.headerType or "none"
|
||
if result.tcp_guise == "http" then
|
||
result.tcp_host = params.host and UrlDecode(params.host) or nil
|
||
result.tcp_path = params.path and UrlDecode(params.path) or nil
|
||
end
|
||
end
|
||
else
|
||
local is_old_format = find_index:find("@") and not find_index:find("://.*@")
|
||
local old_base64, host_port, userinfo, server, port, method, password
|
||
|
||
if is_old_format then
|
||
-- 旧格式:base64(method:pass)@host:port
|
||
old_base64, host_port = find_index:match("^([^@]+)@(.-)$")
|
||
log("SS 节点旧格式解析:", old_base64)
|
||
if not old_base64 or not host_port then
|
||
log("SS 节点旧格式解析失败:", find_index)
|
||
return nil
|
||
end
|
||
local decoded = base64Decode(UrlDecode(old_base64))
|
||
if not decoded then
|
||
log("SS base64 解码失败(旧格式):", old_base64)
|
||
return nil
|
||
end
|
||
userinfo = decoded
|
||
else
|
||
-- 新格式:base64(method:pass@host:port)
|
||
local decoded = base64Decode(UrlDecode(find_index))
|
||
if not decoded then
|
||
log("SS base64 解码失败(新格式):", find_index)
|
||
return nil
|
||
end
|
||
userinfo, host_port = decoded:match("^(.-)@(.-)$")
|
||
if not userinfo or not host_port then
|
||
log("SS 解码内容缺失 @ 分隔:", decoded)
|
||
return nil
|
||
end
|
||
end
|
||
|
||
-- 解析加密方式和密码(允许密码包含冒号)
|
||
local meth_pass = userinfo:find(":")
|
||
if not meth_pass then
|
||
log("SS 用户信息格式错误:", userinfo)
|
||
return nil
|
||
end
|
||
method = userinfo:sub(1, meth_pass - 1)
|
||
password = userinfo:sub(meth_pass + 1)
|
||
|
||
-- 判断密码是否经过url编码
|
||
local function isURLEncodedPassword(pwd)
|
||
if not pwd:find("%%[0-9A-Fa-f][0-9A-Fa-f]") then
|
||
return false
|
||
end
|
||
local ok, decoded = pcall(UrlDecode, pwd)
|
||
return ok and urlEncode(decoded) == pwd
|
||
end
|
||
|
||
local decoded = UrlDecode(password)
|
||
if isURLEncodedPassword(password) and decoded then
|
||
password = decoded
|
||
end
|
||
|
||
-- 解析服务器地址和端口(兼容 IPv6)
|
||
if host_port:find("^%[.*%]:%d+$") then
|
||
server, port = host_port:match("^%[(.*)%]:(%d+)$")
|
||
else
|
||
server, port = host_port:match("^(.-):(%d+)$")
|
||
end
|
||
if not server or not port then
|
||
log("SS 节点服务器信息格式错误:", host_port)
|
||
return nil
|
||
end
|
||
|
||
-- 填充 result
|
||
result.type = selected_ss_backend
|
||
result.encrypt_method_ss = method
|
||
result.password = password
|
||
result.server = server
|
||
result.server_port = port
|
||
|
||
-- 插件处理
|
||
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
|
||
result.plugin_opts = ""
|
||
end
|
||
-- 部分机场下发的插件名为 simple-obfs,这里应该改为 obfs-local
|
||
if result.plugin == "simple-obfs" then
|
||
result.plugin = "obfs-local"
|
||
end
|
||
-- 如果插件不为 none,确保 enable_plugin 为 1
|
||
if result.plugin ~= "none" and result.plugin ~= "" then
|
||
result.enable_plugin = 1
|
||
end
|
||
else
|
||
if params["shadow-tls"] then
|
||
-- 特别处理 shadow-tls 作为插件
|
||
-- log("原始 shadow-tls 参数:", params["shadow-tls"])
|
||
local decoded_tls = base64Decode(UrlDecode(params["shadow-tls"]))
|
||
--log("SS 节点 shadow-tls 解码后:", decoded_tls or "nil")
|
||
if decoded_tls then
|
||
local ok, st = pcall(jsonParse, decoded_tls)
|
||
if ok and st then
|
||
result.plugin = "shadow-tls"
|
||
result.enable_plugin = 1
|
||
local version_flag = ""
|
||
if st.version and tonumber(st.version) then
|
||
version_flag = string.format("v%s=1;", st.version)
|
||
end
|
||
|
||
-- 合成 plugin_opts 格式:v%s=1;host=xxx;password=xxx
|
||
result.plugin_opts = string.format("%shost=%s;passwd=%s",
|
||
version_flag,
|
||
st.host or "",
|
||
st.password or "")
|
||
else
|
||
log("shadow-tls JSON 解析失败")
|
||
end
|
||
end
|
||
end
|
||
end
|
||
|
||
-- 检查加密方法是否受支持
|
||
if not checkTabValue(encrypt_methods_ss)[method] then
|
||
-- 1202 年了还不支持 SS AEAD 的屑机场
|
||
-- log("不支持的SS加密方法:", method)
|
||
result.server = nil
|
||
end
|
||
end
|
||
elseif szType == "sip008" then
|
||
local selected_ss_backend = preferred_ss_backend()
|
||
if not selected_ss_backend then
|
||
return nil
|
||
end
|
||
result.type = selected_ss_backend
|
||
if selected_ss_backend == "v2ray" then
|
||
result.v2ray_protocol = "shadowsocks"
|
||
end
|
||
result.server = content.server
|
||
result.server_port = content.server_port
|
||
result.password = content.password
|
||
result.encrypt_method_ss = content.method
|
||
result.plugin = content.plugin
|
||
result.plugin_opts = content.plugin_opts
|
||
result.raw_alias = content.remarks -- 新增
|
||
result.alias = content.remarks -- 临时赋值(后面会被覆盖)
|
||
if not checkTabValue(encrypt_methods_ss)[content.method] then
|
||
result.server = nil
|
||
end
|
||
elseif szType == "ssd" then
|
||
local selected_ss_backend = preferred_ss_backend()
|
||
if not selected_ss_backend then
|
||
return nil
|
||
end
|
||
result.type = selected_ss_backend
|
||
if selected_ss_backend == "v2ray" then
|
||
result.v2ray_protocol = "shadowsocks"
|
||
end
|
||
result.server = content.server
|
||
result.server_port = content.port
|
||
result.password = content.password
|
||
result.encrypt_method_ss = content.method
|
||
result.plugin_opts = content.plugin_options
|
||
local raw_alias = "[" .. content.airport .. "] " .. content.remarks
|
||
result.raw_alias = raw_alias -- 新增
|
||
result.alias = raw_alias -- 临时赋值(后面会被覆盖)
|
||
if content.plugin == "simple-obfs" then
|
||
result.plugin = "obfs-local"
|
||
else
|
||
result.plugin = content.plugin
|
||
end
|
||
if not checkTabValue(encrypt_methods_ss)[content.encryption] then
|
||
result.server = nil
|
||
end
|
||
elseif szType == "trojan" then
|
||
-- 提取别名(如果存在)
|
||
local alias = ""
|
||
if content:find("#") then
|
||
local idx_sp = content:find("#")
|
||
alias = content:sub(idx_sp + 1, -1)
|
||
content = content:sub(0, idx_sp - 1)
|
||
end
|
||
local raw_alias = UrlDecode(alias)
|
||
result.raw_alias = raw_alias -- 新增
|
||
result.alias = raw_alias -- 临时赋值(后面会被覆盖)
|
||
|
||
-- 分离和提取 password
|
||
local Info = content
|
||
local params = {}
|
||
if Info:find("@") then
|
||
local contents = split(Info, "@")
|
||
result.password = UrlDecode(contents[1])
|
||
local port = "443"
|
||
Info = (contents[2] or ""):gsub("/%?", "?")
|
||
|
||
-- 分离主机和 query 参数(key=value&key2=value2)
|
||
local query = split(Info, "%?")
|
||
local host_port = query[1]
|
||
for _, v in pairs(split(query[2], '&')) do
|
||
local t = split(v, '=')
|
||
if #t > 1 then
|
||
params[string.lower(t[1])] = UrlDecode(t[2])
|
||
end
|
||
end
|
||
|
||
-- 提取服务器地址和端口
|
||
result.server, result.server_port = parse_host_port(host_port, "443")
|
||
|
||
-- 默认设置
|
||
-- 按照官方的建议 默认验证ssl证书
|
||
result.insecure = "0"
|
||
result.tls = "1"
|
||
|
||
-- 处理参数
|
||
if params.alpn and params.alpn ~= "" then
|
||
-- 处理 alpn 参数
|
||
local alpn = {}
|
||
for v in params.alpn:gmatch("[^,;|%s]+") do
|
||
table.insert(alpn, v)
|
||
end
|
||
if #alpn > 0 then
|
||
result.tls_alpn = table.concat(alpn, ",") -- 确保为字符串
|
||
end
|
||
end
|
||
|
||
do
|
||
local tls_host = first_nonempty(params, {"peer", "sni", "host"})
|
||
if tls_host then
|
||
-- 未指定 peer/sni 时,兼容使用 host 作为 TLS Host
|
||
result.tls_host = tls_host
|
||
end
|
||
end
|
||
-- 处理 insecure 参数
|
||
do
|
||
local insecure = first_nonempty(params, {
|
||
"allowInsecure",
|
||
"allowinsecure",
|
||
"allow_insecure",
|
||
"insecure",
|
||
"skip-cert-verify"
|
||
})
|
||
if is_true_value(insecure) then
|
||
result.insecure = "1"
|
||
end
|
||
end
|
||
if params.tfo then
|
||
-- 处理 fast open 参数
|
||
result.fast_open = params.tfo
|
||
end
|
||
else
|
||
result.server_port = port
|
||
end
|
||
|
||
-- 自动决定模式(true=Xray, false=普通 Trojan)
|
||
if not has_xray then
|
||
return nil
|
||
end
|
||
|
||
result.type = "v2ray"
|
||
result.v2ray_protocol = "trojan"
|
||
if params.fp then
|
||
-- 处理 fingerprint 参数
|
||
result.fingerprint = params.fp
|
||
end
|
||
-- 处理 ech 参数
|
||
if params.ech and params.ech ~= "" then
|
||
result.enable_ech = "1"
|
||
result.ech_config = params.ech
|
||
end
|
||
-- 检查 finalmaskg 参数是否存在且非空
|
||
if params.fm and params.fm ~= "" then
|
||
result.enable_finalmask = "1"
|
||
result.finalmaskg = base64Encode(params.fm)
|
||
end
|
||
-- 处理传输协议
|
||
result.transport = params.type or "raw" -- 默认传输协议为 raw
|
||
if result.transport == "tcp" then
|
||
result.transport = "raw"
|
||
end
|
||
if result.transport == "splithttp" then
|
||
result.transport = "xhttp"
|
||
end
|
||
if params.pcs and params.pcs ~= "" then
|
||
result.tls_CertSha = params.pcs
|
||
end
|
||
if params.vcn and params.vcn ~= "" then
|
||
result.tls_CertByName = params.vcn
|
||
end
|
||
if result.transport == "ws" then
|
||
result.ws_host = (result.tls ~= "1") and (params.host and UrlDecode(params.host)) or nil
|
||
result.ws_path = params.path and UrlDecode(params.path) or "/"
|
||
elseif result.transport == "httpupgrade" then
|
||
result.httpupgrade_host = (result.tls ~= "1") and (params.host and UrlDecode(params.host)) or nil
|
||
result.httpupgrade_path = params.path and UrlDecode(params.path) or "/"
|
||
elseif result.transport == "xhttp" or result.transport == "splithttp" then
|
||
result.xhttp_mode = params.mode or "auto"
|
||
result.xhttp_host = params.host and UrlDecode(params.host) or nil
|
||
result.xhttp_path = params.path and UrlDecode(params.path) or "/"
|
||
-- 检查 extra 参数是否存在且非空
|
||
if params.extra and params.extra ~= "" then
|
||
result.enable_xhttp_extra = "1"
|
||
result.xhttp_extra = base64Encode(params.extra)
|
||
end
|
||
-- 尝试解析 JSON 数据
|
||
local success, Data = pcall(jsonParse, params.extra or "")
|
||
if success and type(Data) == "table" then
|
||
local address = (Data.extra and Data.extra.downloadSettings and Data.extra.downloadSettings.address)
|
||
or (Data.downloadSettings and Data.downloadSettings.address)
|
||
result.download_address = (address and address ~= "") and address:gsub("^%[", ""):gsub("%]$", "")
|
||
else
|
||
-- 如果解析失败,清空下载地址
|
||
result.download_address = nil
|
||
end
|
||
elseif result.transport == "http" or result.transport == "h2" then
|
||
result.transport = "h2"
|
||
result.h2_host = params.host and UrlDecode(params.host) or nil
|
||
result.h2_path = params.path and UrlDecode(params.path) or nil
|
||
elseif result.transport == "kcp" then
|
||
result.kcp_guise = params.headerType or "none"
|
||
if params.headerType and params.headerType == "dns" then
|
||
result.kcp_domain = params.host or ""
|
||
end
|
||
result.seed = params.seed
|
||
elseif result.transport == "quic" then
|
||
result.quic_guise = params.headerType or "none"
|
||
result.quic_security = params.quicSecurity or "none"
|
||
result.quic_key = params.key
|
||
elseif result.transport == "grpc" then
|
||
result.serviceName = params.serviceName
|
||
result.grpc_mode = params.mode or "gun"
|
||
elseif result.transport == "tcp" or result.transport == "raw" then
|
||
result.tcp_guise = params.headerType and params.headerType ~= "" and params.headerType or "none"
|
||
if result.tcp_guise == "http" then
|
||
result.tcp_host = params.host and UrlDecode(params.host) or nil
|
||
result.tcp_path = params.path and UrlDecode(params.path) or nil
|
||
end
|
||
end
|
||
elseif szType == "vless" then
|
||
local url = URL.parse("http://" .. content)
|
||
local params = url.query
|
||
|
||
local raw_alias = url.fragment and UrlDecode(url.fragment) or nil
|
||
result.raw_alias = raw_alias -- 新增
|
||
result.alias = raw_alias -- 临时赋值(后面会被覆盖)
|
||
result.type = "v2ray"
|
||
result.v2ray_protocol = "vless"
|
||
result.server = normalize_host(url.host)
|
||
result.server_port = url.port
|
||
result.vmess_id = url.user
|
||
result.vless_encryption = params.encryption or "none"
|
||
|
||
-- 处理传输类型
|
||
result.transport = params.type or "raw"
|
||
if result.transport == "tcp" then
|
||
result.transport = "raw"
|
||
elseif result.transport == "splithttp" then
|
||
result.transport = "xhttp"
|
||
elseif result.transport == "http" then
|
||
result.transport = "h2"
|
||
end
|
||
|
||
-- TLS / Reality 标志
|
||
local security = params.security or ""
|
||
result.tls = (security == "tls" or security == "xtls") and "1" or "0"
|
||
result.reality = (security == "reality") and "1" or "0"
|
||
|
||
-- 统一 TLS / Reality 公共字段
|
||
result.tls_host = params.sni
|
||
result.fingerprint = params.fp
|
||
result.tls_flow = params.flow or nil
|
||
|
||
-- 处理 alpn 列表
|
||
if params.alpn and params.alpn ~= "" then
|
||
local alpn = {}
|
||
for v in params.alpn:gmatch("[^,;|%s]+") do
|
||
table.insert(alpn, v)
|
||
end
|
||
if #alpn > 0 then
|
||
result.tls_alpn = table.concat(alpn, ",") -- 确保为字符串
|
||
end
|
||
end
|
||
|
||
-- 处理 insecure 参数
|
||
if params.allowInsecure or params.insecure then
|
||
local insecure = params.allowInsecure or params.insecure
|
||
if insecure == true or insecure == "1" or insecure == "true" then
|
||
result.insecure = "1"
|
||
end
|
||
end
|
||
|
||
-- ECH 参数(TLS 才有)
|
||
if security == "tls" and params.ech and params.ech ~= "" then
|
||
result.enable_ech = "1"
|
||
result.ech_config = params.ech
|
||
end
|
||
|
||
-- 处理 finalmask 参数
|
||
if params.fm and params.fm ~= "" then
|
||
result.enable_finalmask = "1"
|
||
result.finalmask = base64Encode(params.fm)
|
||
end
|
||
|
||
-- 处理 pinsha256 参数
|
||
if params.pcs and params.pcs ~= "" then
|
||
result.tls_CertSha = params.pcs
|
||
end
|
||
|
||
-- 处理 Leaf Certificate Name 参数
|
||
if params.vcn and params.vcn ~= "" then
|
||
result.tls_CertByName = params.vcn
|
||
end
|
||
|
||
-- Reality 参数
|
||
if security == "reality" then
|
||
result.reality_publickey = params.pbk and UrlDecode(params.pbk) or nil
|
||
result.reality_shortid = params.sid
|
||
result.reality_spiderx = params.spx and UrlDecode(params.spx) or nil
|
||
|
||
-- PQV 验证参数
|
||
if params.pqv and params.pqv ~= "" then
|
||
result.enable_mldsa65verify = "1"
|
||
result.reality_mldsa65verify = params.pqv
|
||
end
|
||
end
|
||
|
||
-- 各种传输类型
|
||
if result.transport == "ws" then
|
||
result.ws_host = (result.tls ~= "1" and result.reality ~= "1") and (params.host and UrlDecode(params.host)) or nil
|
||
result.ws_path = params.path and UrlDecode(params.path) or "/"
|
||
elseif result.transport == "httpupgrade" then
|
||
result.httpupgrade_host = (result.tls ~= "1" and result.reality ~= "1") and (params.host and UrlDecode(params.host)) or nil
|
||
result.httpupgrade_path = params.path and UrlDecode(params.path) or "/"
|
||
elseif result.transport == "xhttp" then
|
||
result.xhttp_mode = params.mode or "auto"
|
||
result.xhttp_host = params.host and UrlDecode(params.host) or nil
|
||
result.xhttp_path = params.path and UrlDecode(params.path) or "/"
|
||
if params.tfo then
|
||
-- 处理 fast open 参数
|
||
result.fast_open = params.tfo
|
||
end
|
||
if params.extra and params.extra ~= "" then
|
||
result.enable_xhttp_extra = "1"
|
||
result.xhttp_extra = base64Encode(params.extra)
|
||
end
|
||
local success, Data = pcall(jsonParse, params.extra or "")
|
||
if success and type(Data) == "table" then
|
||
local address = (Data.extra and Data.extra.downloadSettings and Data.extra.downloadSettings.address)
|
||
or (Data.downloadSettings and Data.downloadSettings.address)
|
||
result.download_address = (address and address ~= "") and address:gsub("^%[", ""):gsub("%]$", "")
|
||
else
|
||
result.download_address = nil
|
||
end
|
||
elseif result.transport == "h2" then
|
||
result.h2_host = params.host and UrlDecode(params.host) or nil
|
||
result.h2_path = params.path and UrlDecode(params.path) or nil
|
||
elseif result.transport == "kcp" then
|
||
result.kcp_guise = params.headerType or "none"
|
||
if params.headerType and params.headerType == "dns" then
|
||
result.kcp_domain = params.host or ""
|
||
end
|
||
result.seed = params.seed
|
||
elseif result.transport == "quic" then
|
||
result.quic_guise = params.headerType or "none"
|
||
result.quic_security = params.quicSecurity or "none"
|
||
result.quic_key = params.key
|
||
elseif result.transport == "grpc" then
|
||
result.serviceName = params.serviceName
|
||
result.grpc_mode = params.mode or "gun"
|
||
elseif result.transport == "raw" then
|
||
result.tcp_guise = params.headerType or "none"
|
||
if result.tcp_guise == "http" then
|
||
result.tcp_host = params.host and UrlDecode(params.host) or nil
|
||
result.tcp_path = params.path and UrlDecode(params.path) or nil
|
||
end
|
||
end
|
||
elseif szType == "tuic" then
|
||
if not tuic_type then
|
||
log("跳过 TUIC 节点:本地未安装 mihomo。")
|
||
return nil
|
||
end
|
||
|
||
-- 提取别名(如果存在)
|
||
local alias = ""
|
||
if content:find("#") then
|
||
local idx_sp = content:find("#")
|
||
alias = content:sub(idx_sp + 1, -1)
|
||
content = content:sub(0, idx_sp - 1)
|
||
end
|
||
local raw_alias = UrlDecode(alias)
|
||
result.raw_alias = raw_alias -- 新增
|
||
result.alias = raw_alias -- 临时赋值(后面会被覆盖)
|
||
|
||
-- 分离和提取 uuid 和 password
|
||
local Info = content
|
||
if Info:find("@") then
|
||
local contents = split(Info, "@")
|
||
local userinfo_raw = UrlDecode(contents[1] or "") -- 如有Url编码进行解码
|
||
if userinfo_raw:find(":") then
|
||
local userinfo = split(userinfo_raw, ":")
|
||
result.tuic_uuid = userinfo[1]
|
||
result.tuic_passwd = userinfo[2]
|
||
end
|
||
Info = (contents[2] or ""):gsub("/%?", "?")
|
||
end
|
||
|
||
-- 分离主机和 query 参数(key=value&key2=value2)
|
||
local query = split(Info, "%?")
|
||
local host_port = query[1]
|
||
local params = {}
|
||
for _, v in pairs(split(query[2], '&')) do
|
||
local t = split(v, '=')
|
||
if #t > 1 then
|
||
params[string.lower(t[1])] = UrlDecode(t[2])
|
||
end
|
||
end
|
||
|
||
-- 提取服务器地址和端口
|
||
result.server, result.server_port = parse_host_port(host_port, "443")
|
||
|
||
result.type = tuic_type
|
||
result.tuic_ip = params.ip or ""
|
||
result.udp_relay_mode = params.udp_relay_mode or "native"
|
||
result.congestion_control = params.congestion_control or "cubic"
|
||
result.heartbeat = params.heartbeat or "3"
|
||
result.timeout = params.timeout or "8"
|
||
result.gc_interval = params.gc_interval or "3"
|
||
result.gc_lifetime = params.gc_lifetime or "15"
|
||
result.send_window = params.send_window or "20971520"
|
||
result.receive_window = params.receive_window or "10485760"
|
||
result.tuic_max_package_size = params.max_packet_size or "1500"
|
||
|
||
-- alpn 支持逗号或分号分隔
|
||
if params.alpn and params.alpn ~= "" then
|
||
local alpn = {}
|
||
for v in params.alpn:gmatch("[^,;|%s]+") do
|
||
table.insert(alpn, v)
|
||
end
|
||
if #alpn > 0 then
|
||
result.tls_alpn = table.concat(alpn, ",") -- 确保为字符串
|
||
end
|
||
end
|
||
|
||
-- 处理 disable_sni 参数
|
||
if params.disable_sni then
|
||
if params.disable_sni == "1" or params.disable_sni == "0" then
|
||
result.disable_sni = params.disable_sni
|
||
else
|
||
result.disable_sni = string.lower(params.disable_sni) == "true" and "1" or "0"
|
||
end
|
||
end
|
||
|
||
-- 处理 zero_rtt_handshake 参数
|
||
if params.zero_rtt_handshake then
|
||
if params.zero_rtt_handshake == "1" or params.zero_rtt_handshake == "0" then
|
||
result.zero_rtt_handshake = params.zero_rtt_handshake
|
||
else
|
||
result.zero_rtt_handshake = string.lower(params.zero_rtt_handshake) == "true" and "1" or "0"
|
||
end
|
||
end
|
||
|
||
-- 处理 dual_stack 参数
|
||
if params.dual_stack then
|
||
if params.dual_stack == "1" or params.dual_stack == "0" then
|
||
result.dual_stack = params.dual_stack
|
||
else
|
||
result.dual_stack = string.lower(params.dual_stack) == "true" and "1" or "0"
|
||
end
|
||
-- 处理 ipstack_prefer 参数
|
||
if params.ipstack_prefer and params.ipstack_prefer ~= "" then
|
||
result.ipstack_prefer = params.ipstack_prefer
|
||
end
|
||
end
|
||
|
||
-- 兼容 allowInsecure / allowlnsecure / insecure
|
||
if params.allowInsecure or params.allowlnsecure or params.insecure then
|
||
local insecure = params.allowInsecure or params.allowlnsecure or params.insecure
|
||
if insecure == true or insecure == "1" or insecure == "true" then
|
||
result.insecure = "1"
|
||
end
|
||
end
|
||
end
|
||
|
||
if not result.type or result.type == "" or result.type == "0" then
|
||
return nil
|
||
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
|
||
result.raw_alias = result.alias
|
||
end
|
||
-- alias 不参与 hashkey 计算
|
||
local alias = result.alias
|
||
result.alias = nil
|
||
local switch_enable = result.switch_enable
|
||
result.switch_enable = nil
|
||
result.hashkey = md5(jsonStringify(result) .. "_" .. (alias or ""))
|
||
result.alias = alias
|
||
result.switch_enable = switch_enable
|
||
return result
|
||
end
|
||
|
||
-- 计算、储存和读取 md5 值
|
||
-- 计算 md5 值
|
||
local function md5_string(data)
|
||
-- 生成临时文件名
|
||
local tmp = "/tmp/md5_tmp_" .. os.time() .. "_" .. math.random(1000,9999) -- os.time 保证每秒唯一,但不足以避免全部冲突;math.random(1000,9999) 增加文件名唯一性,避免并发时冲突
|
||
nixio.fs.writefile(tmp, data) -- 写入临时文件
|
||
-- 执行 md5sum 命令
|
||
local md5 = luci.sys.exec(string.format('md5sum "%s" 2>/dev/null | cut -d " " -f1', tmp)):gsub("%s+", "")
|
||
nixio.fs.remove(tmp) -- 删除临时文件
|
||
return md5
|
||
end
|
||
|
||
-- 返回临时文件路径,用来存储订阅的 MD5 值,以便判断订阅内容是否发生变化。
|
||
local function get_md5_path(groupHash)
|
||
return "/tmp/sub_md5_" .. groupHash
|
||
end
|
||
|
||
-- 读取上次订阅时记录的 MD5 值,以便和当前内容的 MD5 进行对比,从而判断是否需要更新节点列表。
|
||
local function read_old_md5(groupHash)
|
||
local path = get_md5_path(groupHash)
|
||
if nixio.fs.access(path) then
|
||
return trim(nixio.fs.readfile(path) or "")
|
||
end
|
||
return ""
|
||
end
|
||
|
||
-- 将订阅分组最新内容的 MD5 值保存到对应的临时文件中,以便下次更新时进行对比。
|
||
local function write_new_md5(groupHash, md5)
|
||
nixio.fs.writefile(get_md5_path(groupHash), md5)
|
||
end
|
||
|
||
-- curl
|
||
local function curl(url, user_agent)
|
||
-- 清理 URL 中的隐藏字符和前后空白
|
||
url = url:gsub("%s+$", ""):gsub("^%s+", ""):gsub("%z", ""):gsub("[\r\n]", "")
|
||
-- 处理 user_agent 参数
|
||
local ua_opt = ""
|
||
if user_agent and user_agent ~= "" then
|
||
-- 转义双引号,防止破坏 -A 参数
|
||
local safe_ua = user_agent:gsub("[\r\n]", ""):gsub('[\\"`$]', '\\%0') -- 安全转义
|
||
ua_opt = '-A "' .. safe_ua .. '"'
|
||
end
|
||
-- 安全转义 URL:用单引号包裹,并转义内部的单引号
|
||
local safe_url = "'" .. url:gsub("'", "'\\''") .. "'"
|
||
local cmd = string.format(
|
||
'curl -sSL --http1.1 --connect-timeout 20 --max-time 30 --retry 3 -H "Accept-Encoding: identity" %s --insecure --location %s',
|
||
ua_opt,
|
||
safe_url
|
||
)
|
||
-- 执行命令并获取输出
|
||
local stdout = luci.sys.exec(cmd)
|
||
stdout = trim(stdout) -- 确保 trim 函数存在
|
||
local md5 = md5_string(stdout) -- 确保 md5_string 函数存在
|
||
return stdout, md5
|
||
end
|
||
|
||
local function collect_wan_interfaces()
|
||
local ifaces = {}
|
||
local seen = {}
|
||
|
||
local function add_iface(value)
|
||
value = trim(value or "")
|
||
if value == "" or value == "nil" or value:sub(1, 1) == "@" then
|
||
return
|
||
end
|
||
|
||
if value:find("%s") then
|
||
for iface in value:gmatch("%S+") do
|
||
add_iface(iface)
|
||
end
|
||
return
|
||
end
|
||
|
||
if not seen[value] then
|
||
seen[value] = true
|
||
ifaces[#ifaces + 1] = value
|
||
end
|
||
end
|
||
|
||
local function add_iface_from_status(netif)
|
||
local raw = trim(luci.sys.exec(string.format(
|
||
"ubus -S call network.interface.%s status 2>/dev/null",
|
||
netif
|
||
)))
|
||
if raw == "" then
|
||
return
|
||
end
|
||
|
||
local status = jsonParse(raw)
|
||
if type(status) ~= "table" then
|
||
return
|
||
end
|
||
|
||
add_iface(status.l3_device)
|
||
add_iface(status.device)
|
||
end
|
||
|
||
add_iface_from_status("wan")
|
||
add_iface_from_status("wan6")
|
||
|
||
if #ifaces == 0 then
|
||
add_iface(ucic:get("network", "wan", "device"))
|
||
add_iface(ucic:get("network", "wan", "ifname"))
|
||
add_iface(ucic:get("network", "wan6", "device"))
|
||
add_iface(ucic:get("network", "wan6", "ifname"))
|
||
end
|
||
|
||
if #ifaces == 0 then
|
||
local route_output = luci.sys.exec("ip route show default 2>/dev/null")
|
||
for iface in route_output:gmatch("dev%s+(%S+)") do
|
||
add_iface(iface)
|
||
end
|
||
end
|
||
|
||
if #ifaces == 0 then
|
||
local route6_output = luci.sys.exec("ip -6 route show default 2>/dev/null")
|
||
for iface in route6_output:gmatch("dev%s+(%S+)") do
|
||
add_iface(iface)
|
||
end
|
||
end
|
||
|
||
return ifaces
|
||
end
|
||
|
||
local function detect_subscribe_bypass_backend()
|
||
if luci.sys.call("nft list chain inet ss_spec ss_spec_output >/dev/null 2>&1") == 0 then
|
||
return "nftables"
|
||
end
|
||
|
||
local ipt_output = luci.sys.exec("iptables -t nat -S OUTPUT 2>/dev/null")
|
||
if ipt_output:find("SS_SPEC_WAN_AC", 1, true) or ipt_output:find("SS_SPEC_ROUTER", 1, true) then
|
||
return "iptables"
|
||
end
|
||
|
||
return nil
|
||
end
|
||
|
||
local function create_direct_subscribe_bypass()
|
||
if proxy ~= "0" then
|
||
return nil
|
||
end
|
||
|
||
local backend = detect_subscribe_bypass_backend()
|
||
if not backend then
|
||
log("直连订阅: 未检测到 SSR 路由器自身 OUTPUT 代理链,跳过临时绕过规则。")
|
||
return nil
|
||
end
|
||
|
||
local wan_ifaces = collect_wan_interfaces()
|
||
local targets = (#wan_ifaces > 0) and wan_ifaces or {false}
|
||
local tag = string.format("SSR_SUB_BYPASS_%d", tonumber(nixio.getpid()) or os.time())
|
||
local manager = {
|
||
backend = backend,
|
||
tag = tag,
|
||
targets = targets,
|
||
added = false
|
||
}
|
||
|
||
function manager:describe_target(target)
|
||
if target then
|
||
return target
|
||
end
|
||
return "all-output"
|
||
end
|
||
|
||
function manager:apply_iptables_rule(target)
|
||
local cmd = {
|
||
"iptables -t nat -I OUTPUT 1"
|
||
}
|
||
|
||
if target then
|
||
cmd[#cmd + 1] = "-o " .. shell_quote(target)
|
||
end
|
||
|
||
cmd[#cmd + 1] = "-p tcp -m multiport --dports 80,443"
|
||
cmd[#cmd + 1] = "-m comment --comment " .. shell_quote(self.tag)
|
||
cmd[#cmd + 1] = "-j RETURN >/dev/null 2>&1"
|
||
|
||
return luci.sys.call(table.concat(cmd, " ")) == 0
|
||
end
|
||
|
||
function manager:apply_nft_rule(target)
|
||
local rule = {
|
||
"nft insert rule inet ss_spec ss_spec_output"
|
||
}
|
||
|
||
if target then
|
||
rule[#rule + 1] = "oifname " .. nft_string_literal(target)
|
||
end
|
||
|
||
rule[#rule + 1] = "meta l4proto tcp tcp dport { 80, 443 }"
|
||
rule[#rule + 1] = "counter return"
|
||
rule[#rule + 1] = "comment " .. nft_string_literal(self.tag)
|
||
|
||
return luci.sys.call(table.concat(rule, " ") .. " >/dev/null 2>&1") == 0
|
||
end
|
||
|
||
function manager:apply()
|
||
for index = #self.targets, 1, -1 do
|
||
local target = self.targets[index]
|
||
local ok
|
||
|
||
if self.backend == "nftables" then
|
||
ok = self:apply_nft_rule(target)
|
||
else
|
||
ok = self:apply_iptables_rule(target)
|
||
end
|
||
|
||
if ok then
|
||
self.added = true
|
||
log("直连订阅: 已添加临时绕过规则 -> " .. self.backend .. " / " .. self:describe_target(target))
|
||
else
|
||
log("直连订阅: 添加临时绕过规则失败 -> " .. self.backend .. " / " .. self:describe_target(target))
|
||
end
|
||
end
|
||
|
||
if not self.added then
|
||
log("直连订阅: 临时绕过规则未生效,订阅请求仍可能走代理。")
|
||
end
|
||
end
|
||
|
||
function manager:cleanup_nft_rules()
|
||
local output = luci.sys.exec("nft -a list chain inet ss_spec ss_spec_output 2>/dev/null")
|
||
local pattern = 'comment "' .. escape_lua_pattern(self.tag) .. '".-# handle (%d+)'
|
||
local handles = {}
|
||
|
||
for handle in output:gmatch(pattern) do
|
||
handles[#handles + 1] = handle
|
||
end
|
||
|
||
for _, handle in ipairs(handles) do
|
||
luci.sys.call(string.format(
|
||
"nft delete rule inet ss_spec ss_spec_output handle %s >/dev/null 2>&1",
|
||
handle
|
||
))
|
||
end
|
||
|
||
return #handles
|
||
end
|
||
|
||
function manager:cleanup_iptables_rules()
|
||
local removed = 0
|
||
|
||
for _, target in ipairs(self.targets) do
|
||
local cmd = {
|
||
"iptables -t nat -D OUTPUT"
|
||
}
|
||
|
||
if target then
|
||
cmd[#cmd + 1] = "-o " .. shell_quote(target)
|
||
end
|
||
|
||
cmd[#cmd + 1] = "-p tcp -m multiport --dports 80,443"
|
||
cmd[#cmd + 1] = "-m comment --comment " .. shell_quote(self.tag)
|
||
cmd[#cmd + 1] = "-j RETURN >/dev/null 2>&1"
|
||
|
||
if luci.sys.call(table.concat(cmd, " ")) == 0 then
|
||
removed = removed + 1
|
||
end
|
||
end
|
||
|
||
return removed
|
||
end
|
||
|
||
function manager:cleanup()
|
||
if not self.added then
|
||
return
|
||
end
|
||
|
||
local removed
|
||
if self.backend == "nftables" then
|
||
removed = self:cleanup_nft_rules()
|
||
else
|
||
removed = self:cleanup_iptables_rules()
|
||
end
|
||
|
||
log("直连订阅: 已清理临时绕过规则数量: " .. tostring(removed or 0))
|
||
self.added = false
|
||
end
|
||
|
||
return manager
|
||
end
|
||
|
||
local function check_filer(result)
|
||
-- 过滤的关键词列表
|
||
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, nil, true) 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, nil, true) 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
|
||
|
||
-- 加载订阅未变化的节点用于防止被误删
|
||
local function loadOldNodes(groupHash)
|
||
local nodes = {}
|
||
cache[groupHash] = {}
|
||
nodeResult[#nodeResult + 1] = nodes
|
||
local index = #nodeResult
|
||
|
||
ucic:foreach(name, uciType, function(s)
|
||
if s.grouphashkey == groupHash and s.hashkey then
|
||
local section = setmetatable({}, {__index = s})
|
||
nodes[s.hashkey] = section
|
||
cache[groupHash][s.hashkey] = section
|
||
end
|
||
end)
|
||
end
|
||
|
||
local function get_section_ss_backend(section)
|
||
if not section then
|
||
return nil
|
||
end
|
||
if section.type == "ss" or section.type == "ss-libev" then
|
||
return "ss"
|
||
end
|
||
if section.type == "ss-rust" then
|
||
return "ss-rust"
|
||
end
|
||
if section.type == "v2ray" and section.v2ray_protocol == "shadowsocks" then
|
||
return "v2ray"
|
||
end
|
||
return nil
|
||
end
|
||
|
||
local function group_needs_ss_backend_refresh(groupHash)
|
||
local preferred = preferred_ss_backend()
|
||
local has_ss_node = false
|
||
local needs_refresh = false
|
||
|
||
if not preferred then
|
||
return false
|
||
end
|
||
|
||
ucic:foreach(name, uciType, function(s)
|
||
if s.grouphashkey ~= groupHash then
|
||
return
|
||
end
|
||
|
||
local current = get_section_ss_backend(s)
|
||
if current then
|
||
has_ss_node = true
|
||
if current ~= preferred then
|
||
needs_refresh = true
|
||
return false
|
||
end
|
||
end
|
||
end)
|
||
|
||
return has_ss_node and needs_refresh
|
||
end
|
||
|
||
local function preserve_unselected_groups(selected_hashes)
|
||
local preserved = {}
|
||
|
||
ucic:foreach(name, uciType, function(s)
|
||
local groupHash = s.grouphashkey
|
||
if groupHash and groupHash ~= "" and not selected_hashes[groupHash] and not preserved[groupHash] then
|
||
preserved[groupHash] = true
|
||
loadOldNodes(groupHash)
|
||
end
|
||
end)
|
||
end
|
||
|
||
local execute = function()
|
||
local updated = false
|
||
local selected_hashes = {}
|
||
|
||
for _, item in ipairs(subscribe_items) do
|
||
selected_hashes[md5(item.url)] = true
|
||
end
|
||
|
||
if target_subscribe_sid ~= "" then
|
||
preserve_unselected_groups(selected_hashes)
|
||
end
|
||
|
||
for _, item in ipairs(subscribe_items) do
|
||
local url = item.url
|
||
local raw, new_md5 = curl(url, user_agent)
|
||
log("raw 长度: "..#raw)
|
||
local groupHash = md5(url)
|
||
local old_md5 = read_old_md5(groupHash)
|
||
|
||
log("处理订阅: " .. url)
|
||
log("groupHash: " .. groupHash)
|
||
log("old_md5: " .. tostring(old_md5))
|
||
log("new_md5: " .. tostring(new_md5))
|
||
|
||
local backend_refresh = group_needs_ss_backend_refresh(groupHash)
|
||
if #raw == 0 then
|
||
log(url .. ': 获取内容为空')
|
||
loadOldNodes(groupHash)
|
||
elseif old_md5 and new_md5 == old_md5 and not backend_refresh then
|
||
log("订阅未变化, 跳过无需更新的订阅: " .. url)
|
||
-- 防止 diff 阶段误删未更新订阅节点
|
||
loadOldNodes(groupHash)
|
||
--ucic:foreach(name, uciType, function(s)
|
||
-- if s.grouphashkey == groupHash and s.hashkey then
|
||
-- cache[groupHash][s.hashkey] = s
|
||
-- tinsert(nodeResult[index], s)
|
||
-- end
|
||
--end)
|
||
else
|
||
if backend_refresh and old_md5 and new_md5 == old_md5 then
|
||
log("检测到 SS 后端偏好变化,强制重建订阅节点: " .. url)
|
||
end
|
||
updated = true
|
||
-- 保存更新后的 MD5 值到以 groupHash 为标识的临时文件中,用于下次订阅更新时进行对比
|
||
write_new_md5(groupHash, new_md5)
|
||
|
||
cache[groupHash] = {}
|
||
tinsert(nodeResult, {})
|
||
local index = #nodeResult
|
||
local nodes, szType
|
||
local is_clash_subscription = false
|
||
|
||
if isClashYAML(raw) then
|
||
is_clash_subscription = true
|
||
local result = processClashSubscription(url)
|
||
if result and not check_filer(result) and not cache[groupHash][result.hashkey] then
|
||
result.grouphashkey = groupHash
|
||
table.insert(nodeResult[index], result)
|
||
cache[groupHash][result.hashkey] = result
|
||
log('成功导入 Clash 总节点: ' .. result.alias)
|
||
else
|
||
log('丢弃无效 Clash 总节点: ' .. url)
|
||
end
|
||
-- SSD 似乎是这种格式 ssd:// 开头的
|
||
elseif raw:find('ssd://') then
|
||
szType = 'ssd'
|
||
local nEnd = select(2, raw:find('ssd://'))
|
||
nodes = base64Decode(raw:sub(nEnd + 1, #raw))
|
||
nodes = jsonParse(nodes)
|
||
local extra = {
|
||
airport = nodes.airport,
|
||
port = nodes.port,
|
||
encryption = nodes.encryption,
|
||
password = nodes.password
|
||
}
|
||
local servers = {}
|
||
-- SS里面包着 干脆直接这样
|
||
for _, server in ipairs(nodes.servers or {}) do
|
||
tinsert(servers, setmetatable(server, {__index = extra}))
|
||
end
|
||
nodes = servers
|
||
-- SS SIP008 直接使用 Json 格式
|
||
elseif jsonParse(raw) then
|
||
nodes = jsonParse(raw).servers or jsonParse(raw)
|
||
if nodes[1] and nodes[1].server and nodes[1].method then
|
||
szType = 'sip008'
|
||
end
|
||
-- 其他 base64 格式
|
||
else
|
||
-- ssd 外的格式
|
||
nodes = split(base64Decode(raw):gsub("\r\n", "\n"), "\n")
|
||
end
|
||
|
||
if not is_clash_subscription and not szType and type(nodes) == "table" then
|
||
local anytls_nodes = {}
|
||
local normal_nodes = {}
|
||
for _, node in ipairs(nodes) do
|
||
local line = trim(node or "")
|
||
if line:match("^anytls://") then
|
||
local parsed = parseAnytlsShare(line:gsub("^anytls://", ""))
|
||
if parsed then
|
||
table.insert(anytls_nodes, parsed)
|
||
end
|
||
elseif line ~= "" then
|
||
table.insert(normal_nodes, node)
|
||
end
|
||
end
|
||
|
||
if #anytls_nodes > 0 then
|
||
local parsed_url = URL.parse(url)
|
||
local alias = "Clash_" .. (parsed_url.host or groupHash)
|
||
local local_path = string.format("%s/%s.anytls.yaml", local_clash_dir, groupHash)
|
||
local yaml = buildAnytlsClashYaml(anytls_nodes, "Proxy")
|
||
nixio.fs.mkdirr(local_clash_dir)
|
||
nixio.fs.writefile(local_path, yaml)
|
||
|
||
local result = processLocalClashSubscription(local_path, alias)
|
||
if result and not cache[groupHash][result.hashkey] then
|
||
result.grouphashkey = groupHash
|
||
table.insert(nodeResult[index], result)
|
||
cache[groupHash][result.hashkey] = result
|
||
log('成功导入 AnyTLS 转 Clash 总节点: ' .. result.alias)
|
||
end
|
||
end
|
||
|
||
nodes = normal_nodes
|
||
end
|
||
|
||
-- 临时存储该订阅解析出的节点(带原始别名)
|
||
local groupRawNodes = {}
|
||
|
||
if not is_clash_subscription then
|
||
for _, v in ipairs(nodes) do
|
||
if v and not string.match(v, "^%s*$") then
|
||
xpcall(function()
|
||
local result
|
||
if szType then
|
||
result = processData(szType, v)
|
||
elseif not szType then
|
||
local node = trim(v)
|
||
-- 一些奇葩的链接用"&"、"<"当做"&","#"前后带空格
|
||
local link = node:gsub("&[a-zA-Z]+;", "&"):gsub("%s*#%s*", "#")
|
||
local dat = split(link, "://")
|
||
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' or dat[1] == 'tuic' then
|
||
result = processData(dat[1], dat[2] .. dat3)
|
||
else
|
||
result = processData(dat[1], base64Decode(dat[2]))
|
||
end
|
||
end
|
||
else
|
||
log('跳过未知类型: ' .. szType)
|
||
end
|
||
-- log(result)
|
||
if result then
|
||
-- 中文做地址的 也没有人拿中文域名搞,就算中文域也有Puny Code SB 机场
|
||
if not result.server or not result.server_port
|
||
or (result.type ~= "clash" and result.server == "127.0.0.1")
|
||
or result.alias == "NULL"
|
||
or check_filer(result)
|
||
or (result.type ~= "clash" and result.server:match("[^0-9a-zA-Z%-_%.%s]"))
|
||
or cache[groupHash][result.hashkey] then
|
||
log('丢弃无效节点: ' .. result.alias)
|
||
else
|
||
-- 暂存节点
|
||
table.insert(groupRawNodes, result)
|
||
end
|
||
end
|
||
end, function(err)
|
||
log(string.format("解析节点出错: %s\n原始数据: %s", tostring(err), tostring(v)))
|
||
end)
|
||
end
|
||
end
|
||
end
|
||
|
||
-- 对该组节点进行别名编号:重复节点加后缀,唯一节点不加
|
||
local freq = {}
|
||
for _, node in ipairs(groupRawNodes) do
|
||
local raw = node.raw_alias or ""
|
||
freq[raw] = (freq[raw] or 0) + 1
|
||
end
|
||
local aliasCount = {}
|
||
for _, node in ipairs(groupRawNodes) do
|
||
local raw = node.raw_alias or ""
|
||
if freq[raw] > 1 then
|
||
local count = (aliasCount[raw] or 0) + 1
|
||
aliasCount[raw] = count
|
||
node.alias = raw .. "_" .. count
|
||
else
|
||
node.alias = raw
|
||
end
|
||
-- 清理临时字段
|
||
node.raw_alias = nil
|
||
-- 存入 nodeResult
|
||
node.grouphashkey = groupHash
|
||
table.insert(nodeResult[index], node)
|
||
cache[groupHash][node.hashkey] = node
|
||
end
|
||
|
||
log('成功解析节点数量: ' .. #groupRawNodes)
|
||
end
|
||
end
|
||
-- 输出日志并判断是否需要进行 diff
|
||
if not updated then
|
||
log("订阅未变化,无需更新节点信息。")
|
||
log('保留手动添加的节点。')
|
||
return
|
||
end
|
||
|
||
-- diff 阶段
|
||
if next(nodeResult) == nil then
|
||
log("更新失败,没有可用的节点信息")
|
||
return
|
||
end
|
||
local add, del = 0, 0
|
||
ucic:foreach(name, uciType, function(old)
|
||
if old.grouphashkey or old.hashkey then -- 没有 hash 的不参与删除
|
||
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('忽略手动添加的节点: ' .. old.alias)
|
||
else
|
||
ucic:delete(name, old['.name'])
|
||
end
|
||
else
|
||
log('忽略手动添加的节点: ' .. old.alias)
|
||
end
|
||
end
|
||
end)
|
||
-- 1615-1653 行为生成 sid
|
||
-- 记录已使用编号
|
||
local used_sid = {}
|
||
local next_sid = 1
|
||
-- 扫描已有 section
|
||
ucic:foreach(name, uciType, function(s)
|
||
local num = s[".name"]:match("^cfg(%x%x)") -- 提取两位十六进制序号
|
||
if num then
|
||
local n = tonumber(num, 16)
|
||
used_sid[n] = true
|
||
end
|
||
end)
|
||
-- 获取下一个可用编号(O(1))
|
||
local function get_next_sid()
|
||
while used_sid[next_sid] do
|
||
next_sid = next_sid + 1
|
||
end
|
||
used_sid[next_sid] = true
|
||
return next_sid
|
||
end
|
||
|
||
for _, v in ipairs(nodeResult) do
|
||
for _, vv in ipairs(v) do
|
||
if not vv._ignore then
|
||
local sid = ucic:add(name, uciType)
|
||
if sid then
|
||
local suffix = sid:sub(-4)
|
||
ucic:delete(name, sid)
|
||
local id = get_next_sid()
|
||
local cfgid = string.format("cfg%02x%s", id, suffix)
|
||
local section = ucic:section(name, uciType, cfgid)
|
||
if section then
|
||
ucic:tset(name, section, vv)
|
||
ucic:set(name, section, "switch_enable", switch)
|
||
add = add + 1
|
||
end
|
||
end
|
||
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', firstServer)
|
||
ucic:commit(name)
|
||
log('当前主服务器节点已被删除,正在自动更换为第一个节点。')
|
||
luci.sys.call("/etc/init.d/" .. name .. " start > /dev/null 2>&1 &")
|
||
else
|
||
log('维持当前主服务器节点。')
|
||
luci.sys.call("/etc/init.d/" .. name .. " restart > /dev/null 2>&1 &")
|
||
end
|
||
else
|
||
log('没有服务器节点了,停止服务')
|
||
luci.sys.call("/etc/init.d/" .. name .. " stop > /dev/null 2>&1 &")
|
||
end
|
||
end
|
||
log('新增节点数量: ' .. add .. ', 删除节点数量: ' .. del)
|
||
log('订阅更新成功')
|
||
end
|
||
|
||
if subscribe_items and #subscribe_items > 0 then
|
||
if proxy == "1" then
|
||
log("当前订阅模式: 通过代理订阅")
|
||
else
|
||
log("当前订阅模式: 不通过代理订阅")
|
||
end
|
||
local direct_bypass = create_direct_subscribe_bypass()
|
||
if direct_bypass then
|
||
direct_bypass:apply()
|
||
end
|
||
xpcall(execute, function(e)
|
||
log(e)
|
||
log(debug.traceback())
|
||
log('发生错误, 正在尝试恢复服务状态')
|
||
local firstServer = ucic:get_first(name, uciType)
|
||
if firstServer then
|
||
luci.sys.call("/etc/init.d/" .. name .. " restart > /dev/null 2>&1 &") -- 不加&的话日志会出现的更早
|
||
log('重启服务成功')
|
||
else
|
||
luci.sys.call("/etc/init.d/" .. name .. " stop > /dev/null 2>&1 &") -- 不加&的话日志会出现的更早
|
||
log('停止服务成功')
|
||
end
|
||
end)
|
||
if direct_bypass then
|
||
direct_bypass:cleanup()
|
||
end
|
||
end
|