mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-10-31 09:45:13 +08:00
🎉 Sync 2026-09-27 21:03:26
This commit is contained in:
1 parent
f4559bbed1
commit
065763f7fd
673 files changed
+261285
-34160
No files matched your search
@@ -0,0 +1,2 @@
|
||||
# LuCI configuration for luci-app-substore
|
||||
# 订阅元数据、定时更新与规则均已迁移至 /etc/substore/subscriptions.json(按订阅配置),此处不再使用 UCI。
|
||||
@@ -0,0 +1,14 @@
|
||||
#!/bin/sh
|
||||
|
||||
# 迁移:全局定时更新与全局规则均已废弃,改为按订阅配置
|
||||
# 清理旧版遗留的全局 cron 文件与 UCI 设置
|
||||
rm -f /etc/cron.d/substore
|
||||
|
||||
uci -q batch <<-EOF
|
||||
delete substore.settings.cron_enable
|
||||
delete substore.settings.cron_time
|
||||
delete substore.default
|
||||
commit substore
|
||||
EOF
|
||||
|
||||
exit 0
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
#!/bin/sh
|
||||
# substore cron job — 按订阅定时更新
|
||||
# 用法:substore-cron.sh [订阅ID](不传则更新全部已启用订阅)
|
||||
# 注意:显式设置 package.path,确保独立 cron 进程能找到 /usr/lib/lua 下的模块
|
||||
|
||||
DATA_DIR="/etc/substore"
|
||||
LIST_FILE="$DATA_DIR/subscriptions.json"
|
||||
SUB_ID="${1:-}"
|
||||
LUA_BIN=""
|
||||
|
||||
for c in lua5.1 lua; do
|
||||
if command -v "$c" >/dev/null 2>&1; then
|
||||
LUA_BIN="$c"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "$LUA_BIN" ]; then
|
||||
logger -t luci-app-substore "cron: no lua interpreter found"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
[ -f "$LIST_FILE" ] || exit 0
|
||||
|
||||
export SUB_ID
|
||||
LUA_PATH="/usr/lib/lua/?.lua;/usr/share/lua/?.lua;${LUA_PATH:-}" \
|
||||
"$LUA_BIN" -e '
|
||||
local core = require("substore.core")
|
||||
local sub_id = os.getenv("SUB_ID")
|
||||
local ok_count, fail_count = 0, 0
|
||||
|
||||
local function sync_one(id)
|
||||
local ok, err = pcall(function() return core.sync(id) end)
|
||||
if ok then
|
||||
ok_count = ok_count + 1
|
||||
else
|
||||
fail_count = fail_count + 1
|
||||
io.stderr:write("Failed sync " .. tostring(id) .. ": " .. tostring(err) .. "\n")
|
||||
end
|
||||
end
|
||||
|
||||
if sub_id and sub_id ~= "" then
|
||||
sync_one(sub_id)
|
||||
else
|
||||
for _, it in ipairs(core.list()) do
|
||||
if it.enabled then sync_one(it.id) end
|
||||
end
|
||||
end
|
||||
io.stdout:write(string.format("substore cron done: %d ok, %d failed\n", ok_count, fail_count))
|
||||
' 2>&1 | logger -t luci-app-substore
|
||||
@@ -0,0 +1,392 @@
|
||||
-- controller/admin/substore.lua — LuCI 路由注册(Lua 兼容模式)
|
||||
|
||||
module("luci.controller.admin.substore", package.seeall)
|
||||
|
||||
local function back_to_list()
|
||||
local http = require("luci.http")
|
||||
http.redirect(luci.dispatcher.build_url("admin", "services", "substore", "list"))
|
||||
end
|
||||
|
||||
function index()
|
||||
entry({"admin", "services", "substore"}, alias("admin", "services", "substore", "list"), nil)
|
||||
entry({"admin", "services", "substore", "list"}, template("substore/subscriptions"), _("Subscriptions"), 10)
|
||||
entry({"admin", "services", "substore", "form"}, template("substore/form"), nil)
|
||||
entry({"admin", "services", "substore", "localform"}, template("substore/local_form"), nil)
|
||||
entry({"admin", "services", "substore", "nodes"}, template("substore/nodes"), nil)
|
||||
entry({"admin", "services", "substore", "output"}, template("substore/output"), nil)
|
||||
entry({"admin", "services", "substore", "combo"}, template("substore/combo"), nil)
|
||||
entry({"admin", "services", "substore", "create"}, call("action_create"), nil)
|
||||
entry({"admin", "services", "substore", "save"}, call("action_save"), nil)
|
||||
entry({"admin", "services", "substore", "local_create"}, call("action_local_create"), nil)
|
||||
entry({"admin", "services", "substore", "local_save"}, call("action_local_save"), nil)
|
||||
entry({"admin", "services", "substore", "combo_save"}, call("action_combo_save"), nil)
|
||||
entry({"admin", "services", "substore", "node_edit"}, template("substore/node_edit"), nil)
|
||||
entry({"admin", "services", "substore", "node_save"}, call("action_node_save"), nil)
|
||||
entry({"admin", "services", "substore", "node_delete"}, call("action_node_delete"), nil)
|
||||
entry({"admin", "services", "substore", "node_set_group"}, call("action_node_set_group"), nil)
|
||||
entry({"admin", "services", "substore", "delete"}, call("action_delete"), nil)
|
||||
entry({"admin", "services", "substore", "update"}, call("action_update"), nil)
|
||||
entry({"admin", "services", "substore", "probe"}, call("action_probe"), nil)
|
||||
-- public download endpoint token based, no login, for Passwall OpenClash
|
||||
entry({"substore", "download"}, call("action_download"), nil)
|
||||
end
|
||||
|
||||
local function post_ok()
|
||||
-- 拒绝缺失 CSRF token 的 POST
|
||||
local http = require("luci.http")
|
||||
return http.formvalue("token") ~= nil
|
||||
end
|
||||
|
||||
-- read cron fields from form, validate cron expression, return cron_enable cron_time
|
||||
local function read_cron_fields()
|
||||
local http = require("luci.http")
|
||||
local core = require("substore.core")
|
||||
local cron_enable = http.formvalue("cron_enable") or "0"
|
||||
local m = http.formvalue("cron_min") or "0"
|
||||
local h = http.formvalue("cron_hour") or "3"
|
||||
local dom = http.formvalue("cron_dom") or "*"
|
||||
local mon = http.formvalue("cron_mon") or "*"
|
||||
local dow = http.formvalue("cron_dow") or "*"
|
||||
local cron_time = table.concat({m,h,dom,mon,dow}, " ")
|
||||
if not core.cron_time_valid(cron_time) then
|
||||
cron_enable = "0"
|
||||
cron_time = ""
|
||||
end
|
||||
if cron_enable ~= "1" then cron_enable = "0" end
|
||||
return cron_enable, cron_time
|
||||
end
|
||||
|
||||
-- read subscription level rules from form, return rules table
|
||||
local function read_rules_fields()
|
||||
local http = require("luci.http")
|
||||
local proto_list = {}
|
||||
for _, p in ipairs({"vmess","vless","trojan","shadowsocks","ssr","hysteria2","tuic","hysteria","wireguard","socks"}) do
|
||||
if http.formvalue("proto_filter_"..p) then proto_list[#proto_list+1]=p end
|
||||
end
|
||||
local rules_enable = http.formvalue("rules_enable") or "0"
|
||||
if rules_enable ~= "1" then rules_enable = "0" end
|
||||
return {
|
||||
rules_enable = rules_enable,
|
||||
proto_filter = table.concat(proto_list, ","),
|
||||
keyword_include = http.formvalue("keyword_include") or "",
|
||||
keyword_exclude = http.formvalue("keyword_exclude") or "",
|
||||
dedup = http.formvalue("dedup") or "0",
|
||||
rename_map = http.formvalue("rename_map") or "",
|
||||
}
|
||||
end
|
||||
|
||||
function action_create()
|
||||
local http = require("luci.http")
|
||||
local core = require("substore.core")
|
||||
if post_ok() then
|
||||
local name = (http.formvalue("name") or ""):gsub("^%s+", ""):gsub("%s+$", "")
|
||||
local url = (http.formvalue("url") or ""):gsub("^%s+", ""):gsub("%s+$", "")
|
||||
local proxy_enable = http.formvalue("proxy_enable") or "0"
|
||||
if proxy_enable ~= "1" then proxy_enable = "0" end
|
||||
local proxy = (http.formvalue("proxy") or ""):gsub("^%s+", ""):gsub("%s+$", "")
|
||||
if name ~= "" and url ~= "" then
|
||||
local cron_enable, cron_time = read_cron_fields()
|
||||
local rules = read_rules_fields()
|
||||
core.add(name, url, {
|
||||
proxy_enable = proxy_enable, proxy = proxy,
|
||||
cron_enable = cron_enable, cron_time = cron_time,
|
||||
rules_enable = rules.rules_enable, proto_filter = rules.proto_filter,
|
||||
keyword_include = rules.keyword_include, keyword_exclude = rules.keyword_exclude,
|
||||
dedup = rules.dedup, rename_map = rules.rename_map,
|
||||
})
|
||||
core.write_cron()
|
||||
end
|
||||
end
|
||||
back_to_list()
|
||||
end
|
||||
|
||||
function action_save()
|
||||
local http = require("luci.http")
|
||||
local core = require("substore.core")
|
||||
if post_ok() then
|
||||
local id = http.formvalue("id") or ""
|
||||
local name = (http.formvalue("name") or ""):gsub("^%s+", ""):gsub("%s+$", "")
|
||||
local url = (http.formvalue("url") or ""):gsub("^%s+", ""):gsub("%s+$", "")
|
||||
local proxy_enable = http.formvalue("proxy_enable") or "0"
|
||||
if proxy_enable ~= "1" then proxy_enable = "0" end
|
||||
local proxy = (http.formvalue("proxy") or ""):gsub("^%s+", ""):gsub("%s+$", "")
|
||||
if name ~= "" and url ~= "" then
|
||||
local cron_enable, cron_time = read_cron_fields()
|
||||
local rules = read_rules_fields()
|
||||
core.save_meta(id, {
|
||||
name = name, url = url, proxy_enable = proxy_enable, proxy = proxy,
|
||||
cron_enable = cron_enable, cron_time = cron_time,
|
||||
rules_enable = rules.rules_enable, proto_filter = rules.proto_filter,
|
||||
keyword_include = rules.keyword_include, keyword_exclude = rules.keyword_exclude,
|
||||
dedup = rules.dedup, rename_map = rules.rename_map,
|
||||
})
|
||||
core.write_cron()
|
||||
end
|
||||
end
|
||||
back_to_list()
|
||||
end
|
||||
|
||||
function action_local_create()
|
||||
local http = require("luci.http")
|
||||
local core = require("substore.core")
|
||||
if post_ok() then
|
||||
local name = (http.formvalue("name") or ""):gsub("^%s+", ""):gsub("%s+$", "")
|
||||
local content = http.formvalue("content") or ""
|
||||
local local_mode = http.formvalue("local_mode") or "text"
|
||||
local rules = read_rules_fields()
|
||||
if name ~= "" and content ~= "" then
|
||||
core.add_local(name, content, local_mode, {
|
||||
rules_enable = rules.rules_enable,
|
||||
proto_filter = rules.proto_filter,
|
||||
keyword_include = rules.keyword_include,
|
||||
keyword_exclude = rules.keyword_exclude,
|
||||
dedup = rules.dedup,
|
||||
})
|
||||
end
|
||||
end
|
||||
back_to_list()
|
||||
end
|
||||
|
||||
function action_local_save()
|
||||
local http = require("luci.http")
|
||||
local core = require("substore.core")
|
||||
if post_ok() then
|
||||
local id = http.formvalue("id") or ""
|
||||
local name = (http.formvalue("name") or ""):gsub("^%s+", ""):gsub("%s+$", "")
|
||||
local content = http.formvalue("content") or ""
|
||||
local local_mode = http.formvalue("local_mode") or "text"
|
||||
local rules = read_rules_fields()
|
||||
if id ~= "" and name ~= "" and content ~= "" then
|
||||
core.save_meta(id, {
|
||||
name = name,
|
||||
raw_content = content,
|
||||
local_mode = local_mode,
|
||||
rules_enable = rules.rules_enable,
|
||||
proto_filter = rules.proto_filter,
|
||||
keyword_include = rules.keyword_include,
|
||||
keyword_exclude = rules.keyword_exclude,
|
||||
dedup = rules.dedup,
|
||||
})
|
||||
core.sync(id)
|
||||
end
|
||||
end
|
||||
back_to_list()
|
||||
end
|
||||
|
||||
function action_delete()
|
||||
local http = require("luci.http")
|
||||
local core = require("substore.core")
|
||||
if post_ok() then
|
||||
core.remove(http.formvalue("id") or "")
|
||||
core.write_cron()
|
||||
end
|
||||
back_to_list()
|
||||
end
|
||||
|
||||
-- 节点页返回链接:保留当前筛选参数
|
||||
local function back_to_nodes(http)
|
||||
local id = http.formvalue("id") or ""
|
||||
local qs = "?id=" .. luci.util.urlencode(id)
|
||||
for _, k in ipairs({ "proto", "keyword", "group", "sort", "desc" }) do
|
||||
local v = http.formvalue(k)
|
||||
if v and v ~= "" then qs = qs .. "&" .. k .. "=" .. luci.util.urlencode(v) end
|
||||
end
|
||||
http.redirect(luci.dispatcher.build_url("admin", "services", "substore", "nodes") .. qs)
|
||||
end
|
||||
|
||||
-- 单节点保存:表单 JSON 解析后经 merge_form_node 合并到原节点(保留 raw/tags 等非表单字段)
|
||||
function action_node_save()
|
||||
local http = require("luci.http")
|
||||
local core = require("substore.core")
|
||||
local parser = require("substore.parser")
|
||||
if post_ok() then
|
||||
local id = http.formvalue("id") or ""
|
||||
local idx = tonumber(http.formvalue("idx") or "")
|
||||
local content = http.formvalue("content") or ""
|
||||
local nodes = core.read_nodes(id)
|
||||
if idx and nodes[idx] and content ~= "" then
|
||||
local res = parser.parse_local(content, "form")
|
||||
local newn = res and res.nodes and res.nodes[1]
|
||||
if newn then
|
||||
nodes[idx] = core.merge_form_node(nodes[idx], newn)
|
||||
if core.write_nodes(id, nodes) then
|
||||
core.refresh_combos(id)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
back_to_nodes(http)
|
||||
end
|
||||
|
||||
-- 单节点删除
|
||||
function action_node_delete()
|
||||
local http = require("luci.http")
|
||||
local core = require("substore.core")
|
||||
if post_ok() then
|
||||
local id = http.formvalue("id") or ""
|
||||
local idx = tonumber(http.formvalue("idx") or "")
|
||||
local nodes = core.read_nodes(id)
|
||||
if idx and nodes[idx] then
|
||||
table.remove(nodes, idx)
|
||||
if core.write_nodes(id, nodes) then
|
||||
core.save_meta(id, { node_count = #nodes })
|
||||
core.refresh_combos(id)
|
||||
end
|
||||
end
|
||||
end
|
||||
back_to_nodes(http)
|
||||
end
|
||||
|
||||
-- 单节点分组快速设置(XHR,JSON 响应)
|
||||
function action_node_set_group()
|
||||
local http = require("luci.http")
|
||||
local core = require("substore.core")
|
||||
local util = require("substore.util")
|
||||
http.prepare_content("application/json")
|
||||
if not post_ok() then
|
||||
http.write(util.json_encode({ ok = false, err = "forbidden" }))
|
||||
return
|
||||
end
|
||||
local id = http.formvalue("id") or ""
|
||||
local idx = tonumber(http.formvalue("idx") or "")
|
||||
local group = util.trim(http.formvalue("group") or "")
|
||||
local nodes = core.read_nodes(id)
|
||||
if not idx or not nodes[idx] then
|
||||
http.write(util.json_encode({ ok = false, err = "node not found" }))
|
||||
return
|
||||
end
|
||||
nodes[idx].group = group ~= "" and group or nil
|
||||
if not core.write_nodes(id, nodes) then
|
||||
http.write(util.json_encode({ ok = false, err = "write failed" }))
|
||||
return
|
||||
end
|
||||
http.write(util.json_encode({ ok = true }))
|
||||
end
|
||||
|
||||
-- combo save: id empty create else edit, sources from src_id checkboxes and reuse rules
|
||||
function action_combo_save()
|
||||
local http = require("luci.http")
|
||||
local core = require("substore.core")
|
||||
local util = require("substore.util")
|
||||
if post_ok() then
|
||||
local id = util.trim(http.formvalue("id") or "")
|
||||
local name = (http.formvalue("name") or ""):gsub("^%s+", ""):gsub("%s+$", "")
|
||||
local sources = {}
|
||||
for _, it in ipairs(core.list()) do
|
||||
if not it.combo and http.formvalue("src_" .. it.id) then
|
||||
sources[#sources + 1] = it.id
|
||||
end
|
||||
end
|
||||
if name ~= "" then
|
||||
local rules = read_rules_fields()
|
||||
local o = {
|
||||
rules_enable = rules.rules_enable, proto_filter = rules.proto_filter,
|
||||
keyword_include = rules.keyword_include, keyword_exclude = rules.keyword_exclude,
|
||||
dedup = rules.dedup,
|
||||
}
|
||||
if id == "" then
|
||||
core.add_combo(name, sources, o)
|
||||
else
|
||||
core.save_combo(id, name, sources, o)
|
||||
end
|
||||
end
|
||||
end
|
||||
back_to_list()
|
||||
end
|
||||
|
||||
function action_update()
|
||||
local http = require("luci.http")
|
||||
local core = require("substore.core")
|
||||
if post_ok() then
|
||||
local id = http.formvalue("id") or ""
|
||||
local meta = core.get(id)
|
||||
if meta and meta["local"] then
|
||||
-- local subscription does not support auto update
|
||||
return
|
||||
end
|
||||
-- pcall fallback: parse or write error will not 500 the page, error saved to list status
|
||||
local ok, err = pcall(function()
|
||||
return core.sync(id)
|
||||
end)
|
||||
if not ok then
|
||||
core.save_meta(id, { error = tostring(err), last_update = os.time() })
|
||||
end
|
||||
end
|
||||
back_to_list()
|
||||
end
|
||||
|
||||
-- node probe endpoint: POST id + mode ping tcping url + proto + keyword, return JSON
|
||||
function action_probe()
|
||||
local http = require("luci.http")
|
||||
local core = require("substore.core")
|
||||
local node = require("substore.node")
|
||||
local util = require("substore.util")
|
||||
local probe = require("substore.probe")
|
||||
if not post_ok() then
|
||||
http.status(403, "Forbidden")
|
||||
http.prepare_content("text/plain; charset=utf-8")
|
||||
http.write("invalid token")
|
||||
return
|
||||
end
|
||||
local id = util.trim(http.formvalue("id") or "")
|
||||
local mode = util.trim(http.formvalue("mode") or "")
|
||||
if mode ~= "ping" and mode ~= "tcping" and mode ~= "url" then
|
||||
http.status(400, "Bad Request")
|
||||
http.prepare_content("text/plain; charset=utf-8")
|
||||
http.write("bad mode")
|
||||
return
|
||||
end
|
||||
local meta = core.get(id)
|
||||
if not meta then
|
||||
http.status(404, "Not Found")
|
||||
http.prepare_content("text/plain; charset=utf-8")
|
||||
http.write("not found")
|
||||
return
|
||||
end
|
||||
-- 与 nodes 页一致的过滤:按当前 proto / keyword 过滤后逐个探测
|
||||
local nodes = core.read_nodes(id)
|
||||
local proto = util.trim(http.formvalue("proto") or "")
|
||||
local keyword = util.trim(http.formvalue("keyword") or "")
|
||||
if proto ~= "" then nodes = node.filter(nodes, { proto = proto }) end
|
||||
if keyword ~= "" then nodes = node.filter(nodes, { keyword = keyword }) end
|
||||
nodes = node.sort(nodes, "name", false)
|
||||
|
||||
local results = probe.probe(nodes, mode)
|
||||
local ok_count, sum = 0, 0
|
||||
for _, r in ipairs(results) do
|
||||
if r.latency then
|
||||
ok_count = ok_count + 1
|
||||
sum = sum + r.latency
|
||||
end
|
||||
end
|
||||
local avg = ok_count > 0 and math.floor(sum / ok_count + 0.5) or nil
|
||||
http.prepare_content("application/json; charset=utf-8")
|
||||
http.write(util.json_encode({
|
||||
ok = true, mode = mode, total = #results,
|
||||
ok_count = ok_count, avg = avg, results = results,
|
||||
}))
|
||||
end
|
||||
|
||||
-- 公开下载端点:GET /substore/download?token=<token>&target=<format>
|
||||
function action_download()
|
||||
local http = require("luci.http")
|
||||
local core = require("substore.core")
|
||||
local util = require("substore.util")
|
||||
local token = util.trim(http.formvalue("token") or "")
|
||||
local target = util.trim(http.formvalue("target") or "ClashMeta")
|
||||
|
||||
local content, ct, filename, err = core.generate_link(token, target)
|
||||
if not content then
|
||||
http.status(404, "Not Found")
|
||||
http.prepare_content("text/plain; charset=utf-8")
|
||||
http.write(err or "not found")
|
||||
return
|
||||
end
|
||||
|
||||
http.prepare_content(ct)
|
||||
-- 文件名白名单:剥离引号/换行/控制字符,防 HTTP 头注入
|
||||
local safe_name = (filename or "subscription.txt"):gsub("[^%w%-%._]", "_")
|
||||
http.header("Content-Disposition",
|
||||
"attachment; filename=\"" .. safe_name .. "\"")
|
||||
http.write(content)
|
||||
end
|
||||
@@ -0,0 +1,80 @@
|
||||
<%+header%>
|
||||
<%
|
||||
local http = require("luci.http")
|
||||
local core = require("substore.core")
|
||||
local id = http.formvalue("id") or ""
|
||||
local it = id ~= "" and core.get(id) or nil
|
||||
local is_edit = it ~= nil
|
||||
local rules_enabled = it and (it.rules_enable == true or it.rules_enable == "1") or false
|
||||
local ki = (it and it.keyword_include) or ""
|
||||
local ke = (it and it.keyword_exclude) or ""
|
||||
local dedup = it and (it.dedup == "1" or it.dedup == true)
|
||||
local sel = {}
|
||||
if it and type(it.sources) == "table" then
|
||||
for _, s in ipairs(it.sources) do sel[s] = true end
|
||||
end
|
||||
local srcs = {}
|
||||
for _, x in ipairs(core.list()) do
|
||||
if not x.combo then srcs[#srcs + 1] = x end
|
||||
end
|
||||
%>
|
||||
<h2><%=is_edit and luci.i18n.translate("Edit combination") or luci.i18n.translate("Add combination")%></h2>
|
||||
|
||||
<form method="post" action="<%=luci.dispatcher.build_url('admin/services/substore/combo_save')%>" class="cbi-map">
|
||||
<input type="hidden" name="token" value="<%=token%>"/>
|
||||
<% if is_edit then %><input type="hidden" name="id" value="<%=it.id%>"/><% end %>
|
||||
|
||||
<div class="cbi-section">
|
||||
<div class="cbi-section-descr" style="margin:0.5em 0">
|
||||
<label for="name" style="display:inline-block;min-width:10em"><%:Name%></label>
|
||||
<input type="text" id="name" name="name" value="<%=is_edit and it.name or ''%>"/>
|
||||
</div>
|
||||
<div class="cbi-section-descr" style="margin:0.5em 0">
|
||||
<label style="display:inline-block;min-width:10em;vertical-align:top"><%:Source subscriptions%></label>
|
||||
<div style="display:inline-block">
|
||||
<% for _, x in ipairs(srcs) do %>
|
||||
<div style="margin:0.25em 0">
|
||||
<input type="checkbox" id="src_<%=x.id%>" name="src_<%=x.id%>" value="1" <%=sel[x.id] and "checked" or ""%>/>
|
||||
<label for="src_<%=x.id%>"><%=x.name%>(<%=x.node_count or 0%>)</label>
|
||||
</div>
|
||||
<% end %>
|
||||
<% if #srcs == 0 then %>
|
||||
<span style="color:#808080"><%:No subscriptions yet.%></span>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cbi-section-descr" style="margin:0.5em 0">
|
||||
<label style="display:inline-block;min-width:10em">启用规则</label>
|
||||
<input type="checkbox" name="rules_enable" value="1" <%=rules_enabled and "checked" or ""%>
|
||||
onchange="var r=document.getElementById('rules_row');if(r)r.style.display=this.checked?'block':'none';"/>
|
||||
<span class="cbi-value-description">按关键词包含 / 排除、去重规则筛选合并后的节点</span>
|
||||
</div>
|
||||
<div id="rules_row" style="margin:0.5em 0;display:<%=rules_enabled and "block" or "none"%>">
|
||||
<div class="cbi-section-descr" style="margin:0.25em 0">
|
||||
<label style="display:inline-block;min-width:10em">关键词包含</label>
|
||||
<input type="text" name="keyword_include" value="<%=ki%>" style="width:60%"/>
|
||||
</div>
|
||||
<div class="cbi-section-descr" style="margin:0.25em 0">
|
||||
<label style="display:inline-block;min-width:10em">关键词排除</label>
|
||||
<input type="text" name="keyword_exclude" value="<%=ke%>" style="width:60%"/>
|
||||
</div>
|
||||
<div class="cbi-section-descr" style="margin:0.25em 0">
|
||||
<label style="display:inline-block;min-width:10em">去重</label>
|
||||
<input type="checkbox" name="dedup" value="1" <%=dedup and "checked" or ""%>/>
|
||||
</div>
|
||||
<div class="cbi-section-descr" style="margin:0.25em 0">
|
||||
<span style="color:#808080">多条关键词用逗号分隔(如:香港,美国,日本);包含 = 命中任一即保留,排除 = 命中任一即去除。</span>
|
||||
</div>
|
||||
</div>
|
||||
<% if is_edit then %>
|
||||
<div class="cbi-section-descr" style="margin:0.5em 0">
|
||||
<span style="color:#808080"><%:Nodes:%> <%=it.node_count or 0%> | <%:Last updated:%> <%=it.last_update and os.date("%Y-%m-%d %H:%M", it.last_update) or "-"%></span>
|
||||
</div>
|
||||
<% end %>
|
||||
<div class="cbi-page-actions">
|
||||
<input type="submit" class="cbi-button cbi-button-apply" value="<%:Save%>"/>
|
||||
<a class="cbi-button cbi-button-reset" href="<%=luci.dispatcher.build_url('admin/services/substore/list')%>"><%:Back%></a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<%+footer%>
|
||||
@@ -0,0 +1,112 @@
|
||||
<%+header%>
|
||||
<%
|
||||
local http = require("luci.http")
|
||||
local core = require("substore.core")
|
||||
local id = http.formvalue("id") or ""
|
||||
local it = id ~= "" and core.get(id) or nil
|
||||
local is_edit = it ~= nil
|
||||
local cron_enabled = it and (it.cron_enable == true or it.cron_enable == "1") or false
|
||||
local cron = (it and it.cron_time and it.cron_time ~= "") and it.cron_time or "0 3 * * *"
|
||||
local cm, ch, cdom, cmon, cdow = cron:match("^(%S+) (%S+) (%S+) (%S+) (%S+)$")
|
||||
cm = cm or "0"; ch = ch or "3"; cdom = cdom or "*"; cmon = cmon or "*"; cdow = cdow or "*"
|
||||
local rules_enabled = it and (it.rules_enable == true or it.rules_enable == "1") or false
|
||||
local ki = (it and it.keyword_include) or ""
|
||||
local ke = (it and it.keyword_exclude) or ""
|
||||
local dedup = it and (it.dedup == "1" or it.dedup == true)
|
||||
local proxy_enabled = it and (it.proxy_enable == true or it.proxy_enable == "1") or false
|
||||
local util = require("substore.util")
|
||||
-- 剩余流量 / 剩余时长(仅编辑页且有 subscription-userinfo 数据时显示,首页不显示)
|
||||
local traffic_str = nil
|
||||
local time_str = nil
|
||||
if is_edit and it then
|
||||
if it.total then
|
||||
local rem = (tonumber(it.total) or 0) - (tonumber(it.upload) or 0) - (tonumber(it.download) or 0)
|
||||
if rem < 0 then rem = 0 end
|
||||
traffic_str = util.human_bytes(rem)
|
||||
end
|
||||
if it.expire then
|
||||
time_str = util.human_duration((tonumber(it.expire) or 0) - os.time())
|
||||
end
|
||||
end
|
||||
%>
|
||||
<h2><%=is_edit and luci.i18n.translate("Edit subscription") or luci.i18n.translate("Add subscription")%></h2>
|
||||
|
||||
<form method="post" action="<%=luci.dispatcher.build_url('admin/services/substore', is_edit and 'save' or 'create')%>" class="cbi-map">
|
||||
<input type="hidden" name="token" value="<%=token%>"/>
|
||||
<% if is_edit then %><input type="hidden" name="id" value="<%=it.id%>"/><% end %>
|
||||
|
||||
<div class="cbi-section">
|
||||
<div class="cbi-section-descr" style="margin:0.5em 0">
|
||||
<label for="name" style="display:inline-block;min-width:10em"><%:Name%></label>
|
||||
<input type="text" id="name" name="name" value="<%=is_edit and it.name or ''%>"/>
|
||||
</div>
|
||||
<div class="cbi-section-descr" style="margin:0.5em 0">
|
||||
<label for="url" style="display:inline-block;min-width:10em"><%:Subscription URL%></label>
|
||||
<input type="text" id="url" name="url" value="<%=is_edit and it.url or ''%>" style="width:60%"/>
|
||||
</div>
|
||||
<div class="cbi-section-descr" style="margin:0.5em 0">
|
||||
<label style="display:inline-block;min-width:10em">订阅代理</label>
|
||||
<input type="checkbox" name="proxy_enable" value="1" <%=proxy_enabled and "checked" or ""%>
|
||||
onchange="var r=document.getElementById('proxy_row');if(r)r.style.display=this.checked?'block':'none';"/>
|
||||
</div>
|
||||
<div id="proxy_row" class="cbi-section-descr" style="margin:0.5em 0;display:<%=proxy_enabled and "block" or "none"%>">
|
||||
<label for="proxy" style="display:inline-block;min-width:10em">代理地址</label>
|
||||
<input type="text" id="proxy" name="proxy" value="<%=is_edit and it.proxy or ''%>" style="width:60%"/>
|
||||
<div style="color:#808080;margin-top:0.25em">通过代理下载订阅(留空则不使用),支持 http:// 或 socks5:// 地址,如 socks5://192.168.5.1:1080</div>
|
||||
</div>
|
||||
<div class="cbi-section-descr" style="margin:0.5em 0">
|
||||
<label style="display:inline-block;min-width:10em">启用定时更新</label>
|
||||
<input type="checkbox" name="cron_enable" value="1" <%=cron_enabled and "checked" or ""%>
|
||||
onchange="var r=document.getElementById('cron_time_row');if(r)r.style.display=this.checked?'block':'none';"/>
|
||||
</div>
|
||||
<div id="cron_time_row" class="cbi-section-descr" style="margin:0.5em 0;display:<%=cron_enabled and "block" or "none"%>">
|
||||
<label style="display:inline-block;min-width:10em;vertical-align:middle">定时更新时间</label>
|
||||
<span style="display:inline-flex;flex-wrap:wrap;gap:0.5em;vertical-align:middle">
|
||||
<select name="cron_min"><% for i=0,59 do %><option value="<%=i%>" <%=tonumber(cm)==i and "selected" or ""%>><%=i%></option><% end %></select> 分钟
|
||||
<select name="cron_hour"><% for i=0,23 do %><option value="<%=i%>" <%=tonumber(ch)==i and "selected" or ""%>><%=i%></option><% end %></select> 小时
|
||||
<select name="cron_dom"><% for _,v in ipairs({"*","1","2","3","4","5","6","7","8","9","10","11","12","13","14","15","16","17","18","19","20","21","22","23","24","25","26","27","28","29","30","31"}) do %><option value="<%=v%>" <%=cdom==v and "selected" or ""%>><%=v%></option><% end %></select> 日
|
||||
<select name="cron_mon"><% for _,v in ipairs({"*","1","2","3","4","5","6","7","8","9","10","11","12"}) do %><option value="<%=v%>" <%=cmon==v and "selected" or ""%>><%=v%></option><% end %></select> 月
|
||||
<select name="cron_dow"><% for _,v in ipairs({"*","0","1","2","3","4","5","6"}) do %><option value="<%=v%>" <%=cdow==v and "selected" or ""%>><%=v%></option><% end %></select> 周
|
||||
</span>
|
||||
</div>
|
||||
<div class="cbi-section-descr" style="margin:0.5em 0">
|
||||
<label style="display:inline-block;min-width:10em">启用规则</label>
|
||||
<input type="checkbox" name="rules_enable" value="1" <%=rules_enabled and "checked" or ""%>
|
||||
onchange="var r=document.getElementById('rules_row');if(r)r.style.display=this.checked?'block':'none';"/>
|
||||
<span class="cbi-value-description">启用后,更新订阅时按关键词包含/排除、去重规则处理节点</span>
|
||||
</div>
|
||||
<div id="rules_row" style="margin:0.5em 0;display:<%=rules_enabled and "block" or "none"%>">
|
||||
<div class="cbi-section-descr" style="margin:0.25em 0">
|
||||
<label style="display:inline-block;min-width:10em">关键词包含</label>
|
||||
<input type="text" name="keyword_include" value="<%=ki%>" style="width:60%"/>
|
||||
</div>
|
||||
<div class="cbi-section-descr" style="margin:0.25em 0">
|
||||
<label style="display:inline-block;min-width:10em">关键词排除</label>
|
||||
<input type="text" name="keyword_exclude" value="<%=ke%>" style="width:60%"/>
|
||||
</div>
|
||||
<div class="cbi-section-descr" style="margin:0.25em 0">
|
||||
<label style="display:inline-block;min-width:10em">去重</label>
|
||||
<input type="checkbox" name="dedup" value="1" <%=dedup and "checked" or ""%>/>
|
||||
</div>
|
||||
<div class="cbi-section-descr" style="margin:0.25em 0">
|
||||
<span style="color:#808080">多条关键词用逗号分隔(如:香港,美国,日本);包含 = 命中任一即保留,排除 = 命中任一即去除。</span>
|
||||
</div>
|
||||
</div>
|
||||
<% if is_edit and it.last_update then %>
|
||||
<div class="cbi-section-descr" style="margin:0.5em 0">
|
||||
<span style="color:#808080">
|
||||
<%:Nodes:%> <%=it.node_count or 0%>
|
||||
<% if traffic_str then %> | <%:Remaining traffic:%> <%=traffic_str%><% end %>
|
||||
<% if time_str then %> | <%:Remaining time:%> <%=time_str%><% end %>
|
||||
| <%:Last updated:%> <%=os.date("%Y-%m-%d %H:%M", it.last_update)%>
|
||||
<% if it.error and it.error ~= "" then %> | <span style="color:#cc0000"><%=it.error%></span><% end %>
|
||||
</span>
|
||||
</div>
|
||||
<% end %>
|
||||
<div class="cbi-page-actions">
|
||||
<input type="submit" class="cbi-button cbi-button-apply" value="<%:Save%>"/>
|
||||
<a class="cbi-button cbi-button-reset" href="<%=luci.dispatcher.build_url('admin/services/substore/list')%>"><%:Back%></a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<%+footer%>
|
||||
@@ -0,0 +1,172 @@
|
||||
<%+header%>
|
||||
<%
|
||||
local http = require("luci.http")
|
||||
local core = require("substore.core")
|
||||
local id = http.formvalue("id") or ""
|
||||
local it = id ~= "" and core.get(id) or nil
|
||||
local is_edit = it ~= nil
|
||||
local rules_enabled = it and (it.rules_enable == true or it.rules_enable == "1") or false
|
||||
local ki = (it and it.keyword_include) or ""
|
||||
local ke = (it and it.keyword_exclude) or ""
|
||||
local dedup = it and (it.dedup == "1" or it.dedup == true)
|
||||
local local_mode = (it and it.local_mode) or "text"
|
||||
local content = (it and it.raw_content) or ""
|
||||
-- 注入 JS 的初始内容:JSON 字符串字面量,转义 "</" 防止提前闭合 script
|
||||
local util = require("substore.util")
|
||||
local js_content = (util.json_encode(content) or '""'):gsub("</", "<\\/")
|
||||
%>
|
||||
<h2><%=is_edit and luci.i18n.translate("Edit local subscription") or luci.i18n.translate("Add local subscription")%></h2>
|
||||
|
||||
<form method="post" action="<%=luci.dispatcher.build_url('admin/services/substore', is_edit and 'local_save' or 'local_create')%>" class="cbi-map"
|
||||
onsubmit="return substore_local_prepare()">
|
||||
<input type="hidden" name="token" value="<%=token%>"/>
|
||||
<% if is_edit then %><input type="hidden" name="id" value="<%=it.id%>"/><% end %>
|
||||
|
||||
<div class="cbi-section">
|
||||
<div class="cbi-section-descr" style="margin:0.5em 0">
|
||||
<label for="name" style="display:inline-block;min-width:10em"><%:Name%></label>
|
||||
<input type="text" id="name" name="name" value="<%=is_edit and it.name or ''%>"/>
|
||||
</div>
|
||||
<div class="cbi-section-descr" style="margin:0.5em 0">
|
||||
<label style="display:inline-block;min-width:10em;vertical-align:top"><%:Subscription info%></label>
|
||||
<div style="display:inline-block;width:80%">
|
||||
<div style="margin-bottom:0.5em">
|
||||
<select name="local_mode" id="local_mode_sel" onchange="substore_local_switch(this.value)">
|
||||
<option value="text" <%=local_mode=="text" and "selected" or ""%>><%:Text import%></option>
|
||||
<option value="form" <%=local_mode=="form" and "selected" or ""%>><%:Form import%></option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="text_area_wrap" style="display:<%=local_mode=="text" and "block" or "none"%>">
|
||||
<textarea id="content_text" name="content" rows="16" style="width:100%"
|
||||
<%=local_mode=="text" and "" or "disabled"%>><%=luci.util.pcdata(content)%></textarea>
|
||||
<div style="color:#808080;margin-top:0.25em"><%:Paste YAML/URI/JSON node list here. Mixed protocols supported.%></div>
|
||||
</div>
|
||||
<div id="form_nodes_container" style="display:<%=local_mode=="form" and "block" or "none"%>">
|
||||
<div style="margin-bottom:0.5em;color:#808080"><%:Add nodes via form below. Each node will be saved as JSON and parsed on save.%></div>
|
||||
<div id="nodes_list"></div>
|
||||
<button type="button" class="cbi-button cbi-button-apply" onclick="addNodeRow()">+ <%:Add node%></button>
|
||||
<input type="hidden" name="content" id="form_content" value=""
|
||||
<%=local_mode=="form" and "" or "disabled"%>/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cbi-section-descr" style="margin:0.5em 0">
|
||||
<label style="display:inline-block;min-width:10em"><%:Enable rules%></label>
|
||||
<input type="checkbox" name="rules_enable" value="1" <%=rules_enabled and "checked" or ""%>
|
||||
onchange="var r=document.getElementById('rules_row');if(r)r.style.display=this.checked?'block':'none';"/>
|
||||
<span class="cbi-value-description"><%:When enabled, apply keyword include/exclude and dedup rules to nodes%></span>
|
||||
</div>
|
||||
<div id="rules_row" style="margin:0.5em 0;display:<%=rules_enabled and "block" or "none"%>">
|
||||
<div class="cbi-section-descr" style="margin:0.25em 0">
|
||||
<label style="display:inline-block;min-width:10em"><%:Keyword include%></label>
|
||||
<input type="text" name="keyword_include" value="<%=ki%>" style="width:60%"/>
|
||||
</div>
|
||||
<div class="cbi-section-descr" style="margin:0.25em 0">
|
||||
<label style="display:inline-block;min-width:10em"><%:Keyword exclude%></label>
|
||||
<input type="text" name="keyword_exclude" value="<%=ke%>" style="width:60%"/>
|
||||
</div>
|
||||
<div class="cbi-section-descr" style="margin:0.25em 0">
|
||||
<label style="display:inline-block;min-width:10em"><%:Dedup%></label>
|
||||
<input type="checkbox" name="dedup" value="1" <%=dedup and "checked" or ""%>/>
|
||||
</div>
|
||||
</div>
|
||||
<% if is_edit and it.last_update then %>
|
||||
<div class="cbi-section-descr" style="margin:0.5em 0">
|
||||
<span style="color:#808080">
|
||||
<%:Nodes:%> <%=it.node_count or 0%>
|
||||
| <%:Last updated:%> <%=os.date("%Y-%m-%d %H:%M", it.last_update)%>
|
||||
<% if it.error and it.error ~= "" then %> | <span style="color:#cc0000"><%=it.error%></span><% end %>
|
||||
</span>
|
||||
</div>
|
||||
<% end %>
|
||||
<div class="cbi-page-actions">
|
||||
<input type="submit" class="cbi-button cbi-button-apply" value="<%:Save%>"/>
|
||||
<a class="cbi-button cbi-button-reset" href="<%=luci.dispatcher.build_url('admin/services/substore/list')%>"><%:Back%></a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
// 编辑时的初始内容(文本或 JSON),由服务端注入
|
||||
var SUBSTORE_INIT_CONTENT = <%=js_content%>;
|
||||
var SUBSTORE_INIT_MODE = "<%=local_mode%>";
|
||||
|
||||
// 字段标签与界面文本(nodeform.js 读取;翻译由服务端按系统语言渲染)
|
||||
var FIELD_LABELS = {
|
||||
"name": "<%:Name%>",
|
||||
"group": "<%:Group%>",
|
||||
"protocol": "<%:Protocol%>",
|
||||
"server": "<%:Server%>",
|
||||
"port": "<%:Port%>",
|
||||
"password": "<%:Password%>",
|
||||
"uuid": "UUID",
|
||||
"alterId": "Alter ID",
|
||||
"cipher": "<%:Cipher%>",
|
||||
"method": "<%:Encryption method%>",
|
||||
"security": "<%:Security%>",
|
||||
"net": "<%:Network%>",
|
||||
"headerType": "<%:Camouflage type%>",
|
||||
"flow": "Flow",
|
||||
"path": "<%:Path%>",
|
||||
"host": "Host",
|
||||
"sni": "SNI",
|
||||
"tls": "TLS",
|
||||
"obfs": "<%:Obfs%>",
|
||||
"obfs-param": "<%:Obfs param%>",
|
||||
"obfs-password": "<%:Obfs password%>",
|
||||
"protocol-param": "<%:Protocol param%>",
|
||||
"udp": "UDP",
|
||||
"skip-cert-verify": "<%:Skip cert verify%>",
|
||||
"private-key": "<%:Private key%>",
|
||||
"peer-public-key": "<%:Peer public key%>"
|
||||
};
|
||||
var NODEFORM_I18N = {
|
||||
"node": "<%:Node%>",
|
||||
"defaultOpt": "<%:Default%>"
|
||||
};
|
||||
</script>
|
||||
<script src="/luci-static/resources/substore/nodeform.js?v=2.1.3"></script>
|
||||
<script>
|
||||
// 切换导入模式:仅切换显示/禁用,不提交表单
|
||||
function substore_local_switch(mode) {
|
||||
document.getElementById("text_area_wrap").style.display = (mode === "text") ? "block" : "none";
|
||||
document.getElementById("form_nodes_container").style.display = (mode === "form") ? "block" : "none";
|
||||
document.getElementById("content_text").disabled = (mode !== "text");
|
||||
document.getElementById("form_content").disabled = (mode !== "form");
|
||||
if (mode === "form" && document.getElementById("nodes_list").children.length === 0) addNodeRow();
|
||||
}
|
||||
|
||||
// 提交前:表单模式把节点序列化为 JSON 写入隐藏 content 字段
|
||||
function substore_local_prepare() {
|
||||
var mode = document.getElementById("local_mode_sel").value;
|
||||
if (mode === "form") {
|
||||
var hidden = document.getElementById("form_content");
|
||||
hidden.disabled = false;
|
||||
hidden.value = JSON.stringify(collectNodes());
|
||||
document.getElementById("content_text").disabled = true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function initLocalForm() {
|
||||
if (SUBSTORE_INIT_MODE !== "form") return;
|
||||
var data = null;
|
||||
try {
|
||||
data = JSON.parse(SUBSTORE_INIT_CONTENT || "[]");
|
||||
} catch (e) {
|
||||
data = null;
|
||||
}
|
||||
if (data && Object.prototype.toString.call(data) === "[object Array]" && data.length > 0) {
|
||||
for (var i = 0; i < data.length; i++) addNodeRow(data[i]);
|
||||
} else {
|
||||
addNodeRow();
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", initLocalForm);
|
||||
} else {
|
||||
initLocalForm();
|
||||
}
|
||||
</script>
|
||||
<%+footer%>
|
||||
@@ -0,0 +1,99 @@
|
||||
<%+header%>
|
||||
<%
|
||||
local http = require("luci.http")
|
||||
local core = require("substore.core")
|
||||
local util = require("substore.util")
|
||||
local id = http.formvalue("id") or ""
|
||||
local idx = tonumber(http.formvalue("idx") or "") or 0
|
||||
local meta = id ~= "" and core.get(id) or nil
|
||||
local nodes = id ~= "" and core.read_nodes(id) or {}
|
||||
local n = nodes[idx]
|
||||
-- 注入 JS 的节点对象:转义 "</" 防止提前闭合 script
|
||||
local js_node = n and (util.json_encode(n):gsub("</", "<\\/")) or "null"
|
||||
local back_url = luci.dispatcher.build_url('admin/services/substore/nodes') .. "?id=" .. luci.util.urlencode(id)
|
||||
%>
|
||||
<h2><%:Edit node%> — <%=meta and meta.name or ""%></h2>
|
||||
|
||||
<% if not n then %>
|
||||
<div class="cbi-section">
|
||||
<p style="color:#cc0000"><%:Node not found.%></p>
|
||||
<a class="cbi-button cbi-button-reset" href="<%=back_url%>"><%:Back%></a>
|
||||
</div>
|
||||
<% else %>
|
||||
<form method="post" action="<%=luci.dispatcher.build_url('admin/services/substore/node_save')%>" class="cbi-map"
|
||||
onsubmit="return node_edit_prepare()">
|
||||
<input type="hidden" name="token" value="<%=token%>"/>
|
||||
<input type="hidden" name="id" value="<%=id%>"/>
|
||||
<input type="hidden" name="idx" value="<%=idx%>"/>
|
||||
<div class="cbi-section">
|
||||
<div id="nodes_list"></div>
|
||||
<input type="hidden" name="content" id="form_content" value=""/>
|
||||
<div class="cbi-section-descr" style="margin:0.5em 0;color:#808080">
|
||||
<%:Note: edits to this node are overwritten the next time the subscription is updated or re-saved.%>
|
||||
</div>
|
||||
<div class="cbi-page-actions">
|
||||
<input type="submit" class="cbi-button cbi-button-apply" value="<%:Save%>"/>
|
||||
<a class="cbi-button cbi-button-reset" href="<%=back_url%>"><%:Back%></a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
var FIELD_LABELS = {
|
||||
"name": "<%:Name%>",
|
||||
"group": "<%:Group%>",
|
||||
"protocol": "<%:Protocol%>",
|
||||
"server": "<%:Server%>",
|
||||
"port": "<%:Port%>",
|
||||
"password": "<%:Password%>",
|
||||
"uuid": "UUID",
|
||||
"alterId": "Alter ID",
|
||||
"cipher": "<%:Cipher%>",
|
||||
"method": "<%:Encryption method%>",
|
||||
"security": "<%:Security%>",
|
||||
"net": "<%:Network%>",
|
||||
"headerType": "<%:Camouflage type%>",
|
||||
"flow": "Flow",
|
||||
"path": "<%:Path%>",
|
||||
"host": "Host",
|
||||
"sni": "SNI",
|
||||
"tls": "TLS",
|
||||
"obfs": "<%:Obfs%>",
|
||||
"obfs-param": "<%:Obfs param%>",
|
||||
"obfs-password": "<%:Obfs password%>",
|
||||
"protocol-param": "<%:Protocol param%>",
|
||||
"udp": "UDP",
|
||||
"skip-cert-verify": "<%:Skip cert verify%>",
|
||||
"private-key": "<%:Private key%>",
|
||||
"peer-public-key": "<%:Peer public key%>"
|
||||
};
|
||||
var NODEFORM_I18N = {
|
||||
"node": "<%:Node%>",
|
||||
"defaultOpt": "<%:Default%>"
|
||||
};
|
||||
var NODE_EDIT_DATA = <%=js_node%>;
|
||||
</script>
|
||||
<script src="/luci-static/resources/substore/nodeform.js?v=2.1.3"></script>
|
||||
<script>
|
||||
function node_edit_prepare() {
|
||||
var arr = collectNodes();
|
||||
if (arr.length === 0) return false;
|
||||
document.getElementById("form_content").value = JSON.stringify(arr);
|
||||
return true;
|
||||
}
|
||||
|
||||
function initNodeEdit() {
|
||||
var row = addNodeRow(NODE_EDIT_DATA);
|
||||
// 编辑页只有一个节点行,不允许移除
|
||||
var rm = row && row.querySelector(".node_remove");
|
||||
if (rm) rm.style.display = "none";
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", initNodeEdit);
|
||||
} else {
|
||||
initNodeEdit();
|
||||
}
|
||||
</script>
|
||||
<% end %>
|
||||
<%+footer%>
|
||||
@@ -0,0 +1,203 @@
|
||||
<%+header%>
|
||||
<%
|
||||
local http = require("luci.http")
|
||||
local core = require("substore.core")
|
||||
local node = require("substore.node")
|
||||
local ctx = luci.dispatcher.context or {}
|
||||
local q = ctx.query or {}
|
||||
local id = http.formvalue("id") or q.id or ""
|
||||
local meta = id ~= "" and core.get(id) or nil
|
||||
local nodes = id ~= "" and core.read_nodes(id) or {}
|
||||
local proto = http.formvalue("proto") or q.proto or ""
|
||||
local keyword = http.formvalue("keyword") or q.keyword or ""
|
||||
local group = http.formvalue("group") or q.group or ""
|
||||
local sort_by = http.formvalue("sort") or q.sort or "name"
|
||||
local sort_desc = http.formvalue("desc") == "1" or q.desc == "1"
|
||||
|
||||
-- 标注原始数组下标(filter 保留表引用、sort 原地排序,下标可穿透筛选排序)
|
||||
for i, nn in ipairs(nodes) do nn.__idx = i end
|
||||
-- 分组下拉选项:当前订阅节点的去重分组集合
|
||||
local group_set, group_list = {}, {}
|
||||
for _, nn in ipairs(nodes) do
|
||||
local g = nn.group
|
||||
if g and g ~= "" and not group_set[g] then
|
||||
group_set[g] = true
|
||||
group_list[#group_list + 1] = g
|
||||
end
|
||||
end
|
||||
table.sort(group_list)
|
||||
|
||||
if proto ~= "" then nodes = node.filter(nodes, {proto = proto}) end
|
||||
if group ~= "" then nodes = node.filter(nodes, {group = group}) end
|
||||
if keyword ~= "" then nodes = node.filter(nodes, {keyword = keyword}) end
|
||||
nodes = node.sort(nodes, sort_by, sort_desc)
|
||||
local node_edit_base = luci.dispatcher.build_url('admin/services/substore/node_edit')
|
||||
%>
|
||||
<h2><%:Nodes%> — <%=meta and meta.name or ""%></h2>
|
||||
<div style="margin:0.5em 0">
|
||||
<a class="cbi-button cbi-button-reset" href="<%=luci.dispatcher.build_url('admin/services/substore/list')%>"><%:Back%></a>
|
||||
</div>
|
||||
|
||||
<form method="get" action="<%=luci.dispatcher.build_url('admin/services/substore/nodes')%>" class="cbi-map">
|
||||
<input type="hidden" name="id" value="<%=id%>"/>
|
||||
<div style="display:flex;gap:0.5em;align-items:center;flex-wrap:wrap;margin:0.5em 0">
|
||||
<label><%:Protocol%>:
|
||||
<select name="proto">
|
||||
<option value=""><%:All%></option>
|
||||
<% for _, p in ipairs({"vmess","vless","trojan","shadowsocks","ssr","hysteria2","tuic","hysteria","wireguard","socks"}) do %>
|
||||
<option value="<%=p%>" <%=proto==p and "selected" or ""%>><%=p%></option>
|
||||
<% end %>
|
||||
</select>
|
||||
</label>
|
||||
<label><%:Group%>:
|
||||
<select name="group">
|
||||
<option value=""><%:All%></option>
|
||||
<% for _, g in ipairs(group_list) do %>
|
||||
<option value="<%=luci.util.pcdata(g)%>" <%=group==g and "selected" or ""%>><%=g%></option>
|
||||
<% end %>
|
||||
</select>
|
||||
</label>
|
||||
<label><%:Keyword%>: <input type="text" name="keyword" value="<%=keyword%>"/></label>
|
||||
<label><%:Sort by%>:
|
||||
<select name="sort">
|
||||
<option value="name" <%=sort_by=="name" and "selected" or ""%>>name</option>
|
||||
<option value="server" <%=sort_by=="server" and "selected" or ""%>>server</option>
|
||||
<option value="proto" <%=sort_by=="proto" and "selected" or ""%>>proto</option>
|
||||
<option value="port" <%=sort_by=="port" and "selected" or ""%>>port</option>
|
||||
<option value="group" <%=sort_by=="group" and "selected" or ""%>>group</option>
|
||||
</select>
|
||||
</label>
|
||||
<label style="margin-left:0.5em"><input type="checkbox" name="desc" value="1" <%=sort_desc and "checked" or ""%>/> <%:Desc%></label>
|
||||
<input type="submit" class="cbi-button cbi-button-apply" value="<%:Filter%>"/>
|
||||
<button type="button" class="cbi-button" onclick="runProbe('ping')">Ping</button>
|
||||
<button type="button" class="cbi-button" onclick="runProbe('tcping')">TCPing</button>
|
||||
<button type="button" class="cbi-button" onclick="runProbe('url')"><%:URL Test%></button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<table class="cbi-section-table">
|
||||
<tr class="cbi-section-table-titles">
|
||||
<th><%:Name%></th>
|
||||
<th><%:Group%></th>
|
||||
<th><%:Protocol%></th>
|
||||
<th><%:Server%></th>
|
||||
<th><%:Port%></th>
|
||||
<th><%:Latency%></th>
|
||||
<th><%:Actions%></th>
|
||||
</tr>
|
||||
<% for _, n in ipairs(nodes) do %>
|
||||
<tr class="cbi-section-table-row" data-server="<%=n.server or ""%>" data-port="<%=n.port or ""%>">
|
||||
<td><%=n.name or ""%></td>
|
||||
<td>
|
||||
<input type="text" class="group-input" style="width:8em" value="<%=luci.util.pcdata(n.group or "")%>"
|
||||
data-idx="<%=n.__idx%>" onchange="substore_set_group(this)"/>
|
||||
</td>
|
||||
<td><%=n.proto or ""%></td>
|
||||
<td><%=n.server or ""%></td>
|
||||
<td><%=n.port or ""%></td>
|
||||
<td class="node-latency">-</td>
|
||||
<td style="white-space:nowrap">
|
||||
<a class="cbi-button cbi-button-apply" href="<%=node_edit_base%>?id=<%=luci.util.urlencode(id)%>&idx=<%=n.__idx%>"><%:Edit%></a>
|
||||
<form method="post" action="<%=luci.dispatcher.build_url('admin/services/substore/node_delete')%>" style="display:inline"
|
||||
onsubmit="return confirm('<%=luci.util.pcdata(luci.i18n.translate('Delete this node?'))%>');">
|
||||
<input type="hidden" name="token" value="<%=token%>"/>
|
||||
<input type="hidden" name="id" value="<%=id%>"/>
|
||||
<input type="hidden" name="idx" value="<%=n.__idx%>"/>
|
||||
<input type="hidden" name="proto" value="<%=proto%>"/>
|
||||
<input type="hidden" name="keyword" value="<%=keyword%>"/>
|
||||
<input type="hidden" name="group" value="<%=group%>"/>
|
||||
<input type="hidden" name="sort" value="<%=sort_by%>"/>
|
||||
<% if sort_desc then %><input type="hidden" name="desc" value="1"/><% end %>
|
||||
<input type="submit" class="cbi-button cbi-button-reset" value="<%:Delete%>"/>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
<% if #nodes == 0 then %>
|
||||
<tr class="cbi-section-table-row"><td colspan="7" style="text-align:center;color:#808080"><%:No nodes found.%></td></tr>
|
||||
<% end %>
|
||||
</table>
|
||||
<div id="probe_area" style="display:none;margin:0.5em 0">
|
||||
<div id="probe_summary" style="margin:0.5em 0;color:#808080"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function esc(s) {
|
||||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
function substore_set_group(el) {
|
||||
var oldBg = el.style.background;
|
||||
el.disabled = true;
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', '<%=luci.dispatcher.build_url('admin/services/substore/node_set_group')%>', true);
|
||||
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
|
||||
xhr.onreadystatechange = function() {
|
||||
if (xhr.readyState !== 4) return;
|
||||
el.disabled = false;
|
||||
var ok = false;
|
||||
try { ok = xhr.status === 200 && JSON.parse(xhr.responseText).ok === true; } catch (e) {}
|
||||
if (ok) {
|
||||
el.style.background = '#e6ffed';
|
||||
setTimeout(function() { el.style.background = oldBg; }, 800);
|
||||
} else {
|
||||
el.style.background = '#ffe6e6';
|
||||
alert('<%:Failed to set group%>');
|
||||
}
|
||||
};
|
||||
xhr.send('id=' + encodeURIComponent('<%=id%>')
|
||||
+ '&idx=' + encodeURIComponent(el.getAttribute('data-idx'))
|
||||
+ '&group=' + encodeURIComponent(el.value)
|
||||
+ '&token=' + encodeURIComponent('<%=token%>'));
|
||||
}
|
||||
function runProbe(mode) {
|
||||
var area = document.getElementById('probe_area');
|
||||
var sum = document.getElementById('probe_summary');
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', '<%=luci.dispatcher.build_url('admin/services/substore/probe')%>', true);
|
||||
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
|
||||
area.style.display = 'none';
|
||||
sum.textContent = 'Testing...';
|
||||
xhr.onreadystatechange = function() {
|
||||
if (xhr.readyState !== 4) return;
|
||||
if (xhr.status !== 200) {
|
||||
sum.textContent = 'Test failed (HTTP ' + xhr.status + ')';
|
||||
return;
|
||||
}
|
||||
var r;
|
||||
try { r = JSON.parse(xhr.responseText); } catch (e) { sum.textContent = 'Parse error'; return; }
|
||||
// Update latency cells in the main table by matching server+port
|
||||
for (var i = 0; i < r.results.length; i++) {
|
||||
var it = r.results[i];
|
||||
var lat = (it.latency === null || it.latency === undefined)
|
||||
? '<span style="color:#cc0000">Fail</span>'
|
||||
: Math.round(it.latency) + ' ms';
|
||||
// Find row by server and port
|
||||
var rows = document.querySelectorAll('tr[data-server][data-port]');
|
||||
for (var j = 0; j < rows.length; j++) {
|
||||
var row = rows[j];
|
||||
var server = row.getAttribute('data-server');
|
||||
var port = row.getAttribute('data-port');
|
||||
if (server === (it.server || '') && port === String(it.port || '')) {
|
||||
var cell = row.querySelector('.node-latency');
|
||||
if (cell) {
|
||||
cell.innerHTML = lat;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
var avgTxt = (r.avg === null || r.avg === undefined) ? '' : ', avg ' + r.avg + ' ms';
|
||||
sum.textContent = r.ok_count + ' / ' + r.total + ' ok' + avgTxt;
|
||||
};
|
||||
var idEl = document.getElementsByName('id')[0];
|
||||
var protoEl = document.getElementsByName('proto')[0];
|
||||
var kwEl = document.getElementsByName('keyword')[0];
|
||||
var params = 'id=' + encodeURIComponent(idEl ? idEl.value : '')
|
||||
+ '&mode=' + encodeURIComponent(mode)
|
||||
+ '&proto=' + encodeURIComponent(protoEl ? protoEl.value : '')
|
||||
+ '&keyword=' + encodeURIComponent(kwEl ? kwEl.value : '')
|
||||
+ '&token=' + encodeURIComponent('<%=token%>');
|
||||
xhr.send(params);
|
||||
}
|
||||
</script>
|
||||
<%+footer%>
|
||||
@@ -0,0 +1,63 @@
|
||||
<%+header%>
|
||||
<%
|
||||
local http = require("luci.http")
|
||||
local core = require("substore.core")
|
||||
local output = require("substore.output")
|
||||
local util = require("substore.util")
|
||||
local ctx = luci.dispatcher.context or {}
|
||||
local q = ctx.query or {}
|
||||
local id = http.formvalue("id") or q.id or ""
|
||||
local fmt = http.formvalue("fmt") or q.fmt or "clashmeta"
|
||||
local items = core.list()
|
||||
local meta = id ~= "" and core.get(id) or nil
|
||||
local nodes = {}
|
||||
if id ~= "" then
|
||||
nodes = core.read_nodes(id)
|
||||
else
|
||||
-- 合并所有已启用的订阅
|
||||
local ids = {}
|
||||
for _, it in ipairs(items) do
|
||||
if it.enabled then ids[#ids + 1] = it.id end
|
||||
end
|
||||
nodes = core.merge(ids)
|
||||
end
|
||||
local out, err = output.generate(nodes, fmt)
|
||||
%>
|
||||
<h2><%:Output%> — <%=meta and meta.name or luci.i18n.translate("Merged")%></h2>
|
||||
<div style="margin:0.5em 0">
|
||||
<a class="cbi-button cbi-button-reset" href="<%=luci.dispatcher.build_url('admin/services/substore/list')%>"><%:Back%></a>
|
||||
</div>
|
||||
|
||||
<form method="get" action="<%=luci.dispatcher.build_url('admin/services/substore/output')%>" class="cbi-map">
|
||||
<input type="hidden" name="id" value="<%=id%>"/>
|
||||
<div style="display:flex;gap:0.5em;align-items:center;margin:0.5em 0">
|
||||
<label><%:Format%>:
|
||||
<select name="fmt">
|
||||
<option value="clashmeta" <%=fmt=="clashmeta" and "selected" or ""%>>Clash.Meta / Mihomo</option>
|
||||
<option value="stash" <%=fmt=="stash" and "selected" or ""%>>Stash</option>
|
||||
<option value="surge" <%=fmt=="surge" and "selected" or ""%>>Surge</option>
|
||||
<option value="surfboard" <%=fmt=="surfboard" and "selected" or ""%>>Surfboard</option>
|
||||
<option value="surgemac" <%=fmt=="surgemac" and "selected" or ""%>>SurgeMac</option>
|
||||
<option value="loon" <%=fmt=="loon" and "selected" or ""%>>Loon</option>
|
||||
<option value="egern" <%=fmt=="egern" and "selected" or ""%>>Egern</option>
|
||||
<option value="qx" <%=fmt=="qx" and "selected" or ""%>>Quantumult X</option>
|
||||
<option value="shadowrocket" <%=fmt=="shadowrocket" and "selected" or ""%>>Shadowrocket</option>
|
||||
<option value="singbox" <%=fmt=="singbox" and "selected" or ""%>>sing-box</option>
|
||||
<option value="v2ray" <%=fmt=="v2ray" and "selected" or ""%>>V2Ray</option>
|
||||
<option value="v2rayuri" <%=fmt=="v2rayuri" and "selected" or ""%>>V2Ray URI</option>
|
||||
<option value="plain" <%=fmt=="plain" and "selected" or ""%>>Plain JSON</option>
|
||||
</select>
|
||||
</label>
|
||||
<input type="submit" class="cbi-button cbi-button-apply" value="<%:Generate%>"/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<% if err then %>
|
||||
<div style="color:#cc0000"><%=err%></div>
|
||||
<% else %>
|
||||
<textarea id="out_text" readonly style="width:100%;height:300px;font-family:monospace"><%=luci.util.pcdata(out)%></textarea>
|
||||
<div style="margin-top:0.5em">
|
||||
<button onclick="var t=document.getElementById('out_text');t.select();document.execCommand('copy');" class="cbi-button cbi-button-apply"><%:Copy%></button>
|
||||
</div>
|
||||
<% end %>
|
||||
<%+footer%>
|
||||
@@ -0,0 +1,157 @@
|
||||
<%+header%>
|
||||
<h2><%:Subscriptions%></h2>
|
||||
|
||||
<div class="cbi-section">
|
||||
<a class="cbi-button cbi-button-add" href="<%=luci.dispatcher.build_url('admin/services/substore/form')%>">
|
||||
<%:Add subscription link%>
|
||||
</a>
|
||||
<a class="cbi-button cbi-button-add" href="<%=luci.dispatcher.build_url('admin/services/substore/localform')%>">
|
||||
<%:Add local subscription%>
|
||||
</a>
|
||||
<a class="cbi-button cbi-button-add" href="<%=luci.dispatcher.build_url('admin/services/substore/combo')%>">
|
||||
<%:Add combination%>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<table class="cbi-section-table">
|
||||
<tr class="cbi-section-table-titles">
|
||||
<th><%:Name%></th>
|
||||
<th><%:URL%></th>
|
||||
<th><%:Nodes%></th>
|
||||
<th><%:Updated%></th>
|
||||
<th><%:Status%></th>
|
||||
<th><%:Subscription link%></th>
|
||||
<th><%:Actions%></th>
|
||||
</tr>
|
||||
|
||||
<%
|
||||
local core = require("substore.core")
|
||||
local http_env = require("luci.http")
|
||||
local items = core.list()
|
||||
local base = luci.dispatcher.build_url("substore", "download")
|
||||
local scheme = (http_env.getenv("HTTPS") and http_env.getenv("HTTPS") ~= "off") and "https" or "http"
|
||||
local host = http_env.getenv("HTTP_HOST")
|
||||
or (http_env.getenv("SERVER_NAME") and http_env.getenv("SERVER_NAME") .. ":" .. (http_env.getenv("SERVER_PORT") or "80"))
|
||||
or "127.0.0.1"
|
||||
local prefix = scheme .. "://" .. host .. base
|
||||
local name_by_id = {}
|
||||
for _, it2 in ipairs(items) do name_by_id[it2.id] = it2.name end
|
||||
for _, it in ipairs(items) do
|
||||
local status_color = "#808080"
|
||||
local status_text = luci.i18n.translate("Pending")
|
||||
if it.error and it.error ~= "" then
|
||||
status_color = "#cc0000"
|
||||
status_text = it.error
|
||||
elseif it.node_count and it.node_count > 0 then
|
||||
status_color = "#006600"
|
||||
status_text = luci.i18n.translate("OK")
|
||||
end
|
||||
local sub_token = core.ensure_token(it.id)
|
||||
local sub_link_base = prefix .. "?token=" .. sub_token .. "&target="
|
||||
%>
|
||||
<tr class="cbi-section-table-row">
|
||||
<td>
|
||||
<% if it.combo then %>
|
||||
<span class="cbi-value-description" style="color:#0066aa">[<%:Combination%>]</span> <%=it.name%>
|
||||
<% else %>
|
||||
<%=it.name%>
|
||||
<% end %>
|
||||
<% if it.cron_enable == true or it.cron_enable == "1" then %>
|
||||
<div class="cbi-value-description">定时: <%=luci.util.pcdata(it.cron_time or "")%></div>
|
||||
<% end %>
|
||||
</td>
|
||||
<td class="cbi-value-description" style="word-break:break-all"><%
|
||||
if it.combo then
|
||||
local names = {}
|
||||
if type(it.sources) == "table" then
|
||||
for _, sid in ipairs(it.sources) do names[#names + 1] = name_by_id[sid] or sid end
|
||||
end
|
||||
%><span style="color:#808080"><%=table.concat(names, " + ")%></span><%
|
||||
elseif it["local"] then
|
||||
%><span style="color:#0066aa">[<%:Local%>]</span><%
|
||||
else
|
||||
%><%=it.url%><%
|
||||
end
|
||||
%></td>
|
||||
<td><%=it.node_count or 0%></td>
|
||||
<td><%=it.last_update and os.date("%Y-%m-%d %H:%M", it.last_update) or "-"%></td>
|
||||
<td style="color:<%=status_color%>"><%=status_text%></td>
|
||||
<td>
|
||||
<div style="display:flex;gap:0.25em;align-items:center">
|
||||
<select id="fmt_<%=it.id%>" data-base="<%=luci.util.pcdata(sub_link_base)%>"
|
||||
onchange="substore_setlink(this, '<%=it.id%>')">
|
||||
<option value=""><%:Select format%></option>
|
||||
<option value="clashmeta">Clash.Meta / Mihomo</option>
|
||||
<option value="stash">Stash</option>
|
||||
<option value="surge">Surge</option>
|
||||
<option value="surfboard">Surfboard</option>
|
||||
<option value="surgemac">SurgeMac</option>
|
||||
<option value="loon">Loon</option>
|
||||
<option value="egern">Egern</option>
|
||||
<option value="qx">Quantumult X</option>
|
||||
<option value="shadowrocket">Shadowrocket</option>
|
||||
<option value="singbox">sing-box</option>
|
||||
<option value="v2ray">V2Ray</option>
|
||||
<option value="v2rayuri">V2Ray URI</option>
|
||||
<option value="plain">Plain JSON</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="linkwrap_<%=it.id%>" style="display:none;margin-top:0.25em">
|
||||
<div style="display:flex;gap:0.25em;align-items:center">
|
||||
<input id="link_<%=it.id%>" type="text" readonly style="width:280px" value=""/>
|
||||
<button type="button" class="cbi-button cbi-button-apply"
|
||||
onclick="var e=document.getElementById('link_<%=it.id%>');e.select();document.execCommand('copy');"><%:Copy%></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cbi-value-description"><%:选择格式后生成订阅链接,格式可随时切换%></div>
|
||||
</td>
|
||||
<td>
|
||||
<% if it["local"] then %>
|
||||
<span class="cbi-button cbi-button-action" style="opacity:0.5;cursor:not-allowed"><%:Update%></span>
|
||||
<% else %>
|
||||
<form method="post" action="<%=luci.dispatcher.build_url('admin/services/substore/update')%>" style="display:inline">
|
||||
<input type="hidden" name="token" value="<%=token%>"/>
|
||||
<input type="hidden" name="id" value="<%=it.id%>"/>
|
||||
<input type="submit" class="cbi-button cbi-button-action" value="<%:Update%>"/>
|
||||
</form>
|
||||
<% end %>
|
||||
<% if it.combo then %>
|
||||
<a class="cbi-button cbi-button-apply" href="<%=luci.dispatcher.build_url('admin/services/substore/combo')%>?id=<%=luci.util.urlencode(it.id)%>"><%:Edit%></a>
|
||||
<% elseif it["local"] then %>
|
||||
<a class="cbi-button cbi-button-apply" href="<%=luci.dispatcher.build_url('admin/services/substore/localform')%>?id=<%=luci.util.urlencode(it.id)%>"><%:Edit%></a>
|
||||
<% else %>
|
||||
<a class="cbi-button cbi-button-apply" href="<%=luci.dispatcher.build_url('admin/services/substore/form')%>?id=<%=luci.util.urlencode(it.id)%>"><%:Edit%></a>
|
||||
<% end %>
|
||||
<a class="cbi-button cbi-button-apply" href="<%=luci.dispatcher.build_url('admin/services/substore/nodes')%>?id=<%=luci.util.urlencode(it.id)%>"><%:Nodes%></a>
|
||||
<a class="cbi-button cbi-button-apply" href="<%=luci.dispatcher.build_url('admin/services/substore/output')%>?id=<%=luci.util.urlencode(it.id)%>"><%:Output%></a>
|
||||
<form method="post" action="<%=luci.dispatcher.build_url('admin/services/substore/delete')%>" style="display:inline"
|
||||
onsubmit="return confirm('<%=luci.util.pcdata(luci.i18n.translate('Delete this subscription?'))%>');">
|
||||
<input type="hidden" name="token" value="<%=token%>"/>
|
||||
<input type="hidden" name="id" value="<%=it.id%>"/>
|
||||
<input type="submit" class="cbi-button cbi-button-reset" value="<%:Delete%>"/>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<%
|
||||
end
|
||||
if #items == 0 then
|
||||
%>
|
||||
<tr class="cbi-section-table-row">
|
||||
<td colspan="7" style="text-align:center;color:#808080"><%:No subscriptions yet.%></td>
|
||||
</tr>
|
||||
<%
|
||||
end
|
||||
%>
|
||||
</table>
|
||||
<script>
|
||||
function substore_setlink(sel, id) {
|
||||
var wrap = document.getElementById('linkwrap_' + id);
|
||||
if (!wrap) return;
|
||||
if (!sel.value) { wrap.style.display = 'none'; return; }
|
||||
var inp = document.getElementById('link_' + id);
|
||||
var base = sel.getAttribute('data-base') || '';
|
||||
inp.value = base + encodeURIComponent(sel.value);
|
||||
wrap.style.display = 'block';
|
||||
}
|
||||
</script>
|
||||
<%+footer%>
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"admin/services/substore": {
|
||||
"title": "Subscriptions",
|
||||
"order": 60,
|
||||
"action": {
|
||||
"type": "view",
|
||||
"path": "admin/services/substore/list"
|
||||
}
|
||||
},
|
||||
"admin/services/substore/list": {
|
||||
"title": "Subscriptions",
|
||||
"order": 10,
|
||||
"action": {
|
||||
"type": "view",
|
||||
"path": "admin/services/substore/list"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"luci-app-substore": {
|
||||
"description": "Grant UCI access for luci-app-substore",
|
||||
"read": {
|
||||
"uci": [ "substore" ]
|
||||
},
|
||||
"write": {
|
||||
"uci": [ "substore" ]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
-- converter.lua — 主转换引擎(纯 Lua)
|
||||
-- luci-app-substore
|
||||
|
||||
local M = {}
|
||||
|
||||
local node = require("substore.node")
|
||||
local node_converter = require("substore.node_converter")
|
||||
local output = require("substore.output")
|
||||
local util = require("substore.util")
|
||||
|
||||
-- 随机种子
|
||||
math.randomseed(os.time())
|
||||
|
||||
-- 验证节点完整性
|
||||
function M.validate_nodes(nodes)
|
||||
if type(nodes) ~= "table" then
|
||||
return false, { "nodes must be a table" }
|
||||
end
|
||||
local errors = {}
|
||||
for i, n in ipairs(nodes) do
|
||||
if type(n) ~= "table" then
|
||||
errors[#errors + 1] = string.format("node %d is not a table", i)
|
||||
else
|
||||
if not n.proto or n.proto == "" then
|
||||
errors[#errors + 1] = string.format("node %d missing proto", i)
|
||||
end
|
||||
if not n.server or n.server == "" then
|
||||
errors[#errors + 1] = string.format("node %d missing server", i)
|
||||
end
|
||||
if n.port == nil then
|
||||
errors[#errors + 1] = string.format("node %d missing port", i)
|
||||
else
|
||||
local p = tonumber(n.port)
|
||||
if not p or p <= 0 then
|
||||
errors[#errors + 1] = string.format("node %d invalid port", i)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
if #errors > 0 then
|
||||
return false, errors
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
-- 协议转换(单节点)
|
||||
function M.convert_protocol(node, target_proto)
|
||||
if type(node) ~= "table" or not node.proto or not target_proto then
|
||||
return nil
|
||||
end
|
||||
if node.proto == target_proto then
|
||||
local copy = {}
|
||||
for k, v in pairs(node) do copy[k] = v end
|
||||
return copy
|
||||
end
|
||||
|
||||
local src = node.proto:lower()
|
||||
local tgt = target_proto:lower()
|
||||
if src == "ss" then src = "shadowsocks" end
|
||||
if tgt == "ss" then tgt = "shadowsocks" end
|
||||
|
||||
-- 禁止涉及 shadowsocks 的转换(测试要求)
|
||||
if src == "shadowsocks" or tgt == "shadowsocks" then
|
||||
return nil
|
||||
end
|
||||
|
||||
-- 允许的转换对
|
||||
local allowed = {
|
||||
vmess = { vless = true, trojan = true },
|
||||
vless = { vmess = true, trojan = true },
|
||||
trojan = { vmess = true, vless = true },
|
||||
hysteria2 = { ["sing-box"] = true, clash = true },
|
||||
tuic = { ["sing-box"] = true, clash = true },
|
||||
wireguard = { ["sing-box"] = true, clash = true },
|
||||
}
|
||||
local allowed_map = allowed[src]
|
||||
if not allowed_map or not allowed_map[tgt] then
|
||||
return nil
|
||||
end
|
||||
|
||||
local mapping = node_converter.get_field_mapping(src, tgt)
|
||||
if not mapping then
|
||||
return nil
|
||||
end
|
||||
|
||||
local new_node = {}
|
||||
-- 先按映射复制
|
||||
for src_field, tgt_field in pairs(mapping) do
|
||||
if src_field == "proto" then
|
||||
-- skip, set later
|
||||
elseif tgt_field then
|
||||
if node[src_field] ~= nil then
|
||||
new_node[tgt_field] = node[src_field]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- 保留未在映射中明确排除的字段
|
||||
for k, v in pairs(node) do
|
||||
if k ~= "proto" then
|
||||
local mapped = mapping[k]
|
||||
if mapped == nil then
|
||||
-- 未映射的字段保留原名
|
||||
if new_node[k] == nil then
|
||||
new_node[k] = v
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
new_node.proto = tgt
|
||||
|
||||
-- 保留 name
|
||||
if not new_node.name and node.name then
|
||||
new_node.name = node.name
|
||||
elseif not new_node.name then
|
||||
new_node.name = (node.server or "") .. ":" .. tostring(node.port or "")
|
||||
end
|
||||
|
||||
-- 保留 server / port
|
||||
if not new_node.server and node.server then
|
||||
new_node.server = node.server
|
||||
end
|
||||
if not new_node.port and node.port then
|
||||
new_node.port = node.port
|
||||
end
|
||||
|
||||
-- trojan -> vmess/vless 生成新 uuid,避免直接复用密码
|
||||
if src == "trojan" and (tgt == "vmess" or tgt == "vless") then
|
||||
local seed = tostring(os.time()) .. tostring(math.random(100000, 999999))
|
||||
new_node.uuid = util.base64_encode(seed):sub(1, 36)
|
||||
end
|
||||
|
||||
-- 丢弃源协议专属字段(不带入转换后的协议)
|
||||
local drop = {
|
||||
vmess = { alterId = true, cipher = true },
|
||||
}
|
||||
if drop[src] then
|
||||
for f in pairs(drop[src]) do
|
||||
new_node[f] = nil
|
||||
end
|
||||
end
|
||||
|
||||
return new_node
|
||||
end
|
||||
|
||||
-- 主转换:节点列表 -> 目标格式(clash/json/base64)
|
||||
function M.convert(nodes, target_format, options)
|
||||
options = options or {}
|
||||
if type(nodes) ~= "table" then
|
||||
return nil, "nodes must be table"
|
||||
end
|
||||
|
||||
-- 可选:协议统一转换
|
||||
local processed = nodes
|
||||
if options.proto then
|
||||
local out = {}
|
||||
for _, n in ipairs(nodes) do
|
||||
local cn = M.convert_protocol(n, options.proto)
|
||||
if cn then
|
||||
out[#out + 1] = cn
|
||||
end
|
||||
end
|
||||
processed = out
|
||||
end
|
||||
|
||||
-- 可选:流水线处理
|
||||
if options.pipeline then
|
||||
processed = M.process_pipeline(processed, options.pipeline)
|
||||
end
|
||||
|
||||
local fmt = (target_format or "clash"):lower()
|
||||
local content, err = output.generate(processed, fmt)
|
||||
return content, err
|
||||
end
|
||||
|
||||
-- 多步骤流水线:filter -> rename -> group -> convert
|
||||
function M.process_pipeline(nodes, pipeline)
|
||||
if type(nodes) ~= "table" then
|
||||
return nodes
|
||||
end
|
||||
pipeline = pipeline or {}
|
||||
|
||||
-- 深浅拷贝
|
||||
local current = {}
|
||||
for _, n in ipairs(nodes) do
|
||||
local cp = {}
|
||||
for k, v in pairs(n) do cp[k] = v end
|
||||
current[#current + 1] = cp
|
||||
end
|
||||
|
||||
for _, step in ipairs(pipeline) do
|
||||
if type(step) ~= "table" then
|
||||
-- skip
|
||||
else
|
||||
local t = step.type
|
||||
if t == "filter" then
|
||||
current = node.filter(current, step.opts or {})
|
||||
elseif t == "rename" then
|
||||
if step.opts then
|
||||
if step.opts.prefix then
|
||||
for _, n in ipairs(current) do
|
||||
if n.name then
|
||||
n.name = step.opts.prefix .. n.name
|
||||
end
|
||||
end
|
||||
end
|
||||
if step.opts.map then
|
||||
for _, n in ipairs(current) do
|
||||
if n.name and step.opts.map[n.name] then
|
||||
n.name = step.opts.map[n.name]
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
elseif t == "group" then
|
||||
if step.opts and step.opts.field then
|
||||
local field = step.opts.field
|
||||
local prefix = step.opts.prefix or "group_"
|
||||
for _, n in ipairs(current) do
|
||||
local val = n[field] or ""
|
||||
n.group = prefix .. tostring(val)
|
||||
end
|
||||
end
|
||||
elseif t == "convert" then
|
||||
if step.opts and step.opts.proto then
|
||||
local out = {}
|
||||
for _, n in ipairs(current) do
|
||||
local cn = M.convert_protocol(n, step.opts.proto)
|
||||
if cn then
|
||||
out[#out + 1] = cn
|
||||
end
|
||||
end
|
||||
current = out
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return current
|
||||
end
|
||||
|
||||
-- 应用模板到节点(简单占位符替换)
|
||||
function M.apply_template(nodes, template)
|
||||
if type(nodes) ~= "table" then
|
||||
return nodes
|
||||
end
|
||||
template = template or ""
|
||||
local out = {}
|
||||
for _, n in ipairs(nodes) do
|
||||
local cp = {}
|
||||
for k, v in pairs(n) do cp[k] = v end
|
||||
if cp.name and template ~= "" then
|
||||
local new_name = template
|
||||
new_name = new_name:gsub("{{name}}", cp.name or "")
|
||||
new_name = new_name:gsub("{{proto}}", cp.proto or "")
|
||||
new_name = new_name:gsub("{{server}}", cp.server or "")
|
||||
new_name = new_name:gsub("{{port}}", tostring(cp.port or ""))
|
||||
cp.name = new_name
|
||||
end
|
||||
out[#out + 1] = cp
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- ---------- 模板渲染引擎 ----------
|
||||
|
||||
-- 协议 URL 模板常量
|
||||
M.URL_TEMPLATES = {
|
||||
vmess = "vmess://{uuid}@{server}:{port}#{name}",
|
||||
vless = "vless://{uuid}@{server}:{port}#{name}",
|
||||
trojan = "trojan://{password}@{server}:{port}#{name}",
|
||||
ss = "ss://{method}:{password}@{server}:{port}#{name}",
|
||||
}
|
||||
|
||||
-- 读取节点字段并归一化为字符串;缺失或空返回 nil
|
||||
local function field_str(node, field)
|
||||
local v = node[field]
|
||||
if v == nil then return nil end
|
||||
if type(v) == "boolean" then return tostring(v) end
|
||||
local s = tostring(v)
|
||||
if s == "" then return nil end
|
||||
return s
|
||||
end
|
||||
|
||||
-- 渲染单个 {} 表达式:
|
||||
-- {field} 变量替换
|
||||
-- {field|default} 缺失时取默认值
|
||||
-- {upper:field} 转大写
|
||||
-- {lower:field} 转小写
|
||||
-- {if:field?真值:假值} 条件渲染
|
||||
local function render_expr(expr, node)
|
||||
expr = expr or ""
|
||||
-- 条件渲染(需最先判断,否则被 "if:..." 与函数分支误匹配)
|
||||
local field, tv, fv = expr:match("^if:([^?]+)%?(.-):(.*)$")
|
||||
if field then
|
||||
return (field_str(node, field) ~= nil) and tv or fv
|
||||
end
|
||||
-- 函数调用
|
||||
local fn, fname = expr:match("^([%a]+):(.+)$")
|
||||
if fn == "upper" or fn == "lower" then
|
||||
local v = field_str(node, fname) or ""
|
||||
if fn == "upper" then return v:upper() else return v:lower() end
|
||||
end
|
||||
-- 默认值
|
||||
local fname2, dflt = expr:match("^([^|]+)|(.*)$")
|
||||
if fname2 then
|
||||
local v = field_str(node, fname2)
|
||||
return (v ~= nil) and v or dflt
|
||||
end
|
||||
-- 普通变量
|
||||
return field_str(node, expr) or ""
|
||||
end
|
||||
|
||||
-- 渲染单个模板字符串到节点
|
||||
function M.render_template(template, node)
|
||||
if type(template) ~= "string" then return "" end
|
||||
node = node or {}
|
||||
return (template:gsub("{([^{}]*)}", function(expr)
|
||||
return render_expr(expr, node)
|
||||
end))
|
||||
end
|
||||
|
||||
-- 批量渲染:节点列表 -> 渲染结果数组
|
||||
function M.render_url_template(nodes, template)
|
||||
local out = {}
|
||||
if type(nodes) ~= "table" then return out end
|
||||
for _, n in ipairs(nodes) do
|
||||
out[#out + 1] = M.render_template(template, n)
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -0,0 +1,525 @@
|
||||
-- core.lua — 订阅源元数据、节点数据与状态管理(纯 Lua,无 luci.* 依赖)
|
||||
-- luci-app-substore
|
||||
|
||||
local util = require("substore.util")
|
||||
local http = require("substore.http")
|
||||
local parser = require("substore.parser")
|
||||
|
||||
local M = {}
|
||||
|
||||
M.version = "2.1.3"
|
||||
M.DATA_DIR = "/etc/substore"
|
||||
M.LIST_FILE = M.DATA_DIR .. "/subscriptions.json"
|
||||
M.NODES_DIR = M.DATA_DIR .. "/nodes"
|
||||
M.CRON_FILE = "/etc/cron.d/substore"
|
||||
|
||||
M.MAX_SIZE = 10 * 1024 * 1024 -- 10MB
|
||||
M.TIMEOUT = 20
|
||||
|
||||
local function id_is_valid(id)
|
||||
return type(id) == "string" and id ~= "" and id:match("^[A-Za-z0-9_%-]+$") ~= nil
|
||||
end
|
||||
|
||||
function M.ensure_dirs()
|
||||
util.ensure_dir(M.DATA_DIR)
|
||||
util.ensure_dir(M.NODES_DIR)
|
||||
end
|
||||
|
||||
local function load()
|
||||
M.ensure_dirs()
|
||||
local raw = util.read_file(M.LIST_FILE)
|
||||
if not raw or raw == "" then return 0, {} end
|
||||
local data = util.json_decode(raw)
|
||||
if type(data) ~= "table" then return 0, {} end
|
||||
local seq = tonumber(data._seq) or 0
|
||||
local items = type(data.items) == "table" and data.items or {}
|
||||
return seq, items
|
||||
end
|
||||
|
||||
local function save(seq, items)
|
||||
M.ensure_dirs()
|
||||
return util.atomic_write(M.LIST_FILE, util.json_encode({ _seq = seq, items = items }))
|
||||
end
|
||||
|
||||
function M.list()
|
||||
local _, items = load()
|
||||
local arr = {}
|
||||
for id, meta in pairs(items) do
|
||||
local m = {}
|
||||
for k, v in pairs(meta) do m[k] = v end
|
||||
m.id = id
|
||||
arr[#arr + 1] = m
|
||||
end
|
||||
table.sort(arr, function(a, b) return (a.name or "") < (b.name or "") end)
|
||||
return arr
|
||||
end
|
||||
|
||||
function M.get(id)
|
||||
if not id_is_valid(id) then return nil end
|
||||
local _, items = load()
|
||||
local meta = items[id]
|
||||
if not meta then return nil end
|
||||
local m = {}
|
||||
for k, v in pairs(meta) do m[k] = v end
|
||||
m.id = id
|
||||
return m
|
||||
end
|
||||
|
||||
function M.add(name, url, opts)
|
||||
name = util.trim(name or "")
|
||||
url = util.trim(url or "")
|
||||
opts = opts or {}
|
||||
if name == "" or url == "" then return nil, "名称/URL 不能为空" end
|
||||
local seq, items = load()
|
||||
seq = seq + 1
|
||||
local id = string.format("s%08x", seq)
|
||||
local cron_time = util.trim(opts.cron_time or "")
|
||||
if not M.cron_time_valid(cron_time) then cron_time = "" end
|
||||
items[id] = {
|
||||
name = name, url = url, enabled = true,
|
||||
node_count = 0, last_update = nil, error = "", format = "",
|
||||
token = util.rnd_hex(16),
|
||||
proxy_enable = (opts.proxy_enable == true or opts.proxy_enable == "1") and "1" or "0",
|
||||
proxy = util.trim(opts.proxy or ""),
|
||||
cron_enable = (opts.cron_enable == true or opts.cron_enable == "1") and cron_time ~= "",
|
||||
cron_time = cron_time,
|
||||
rules_enable = (opts.rules_enable == true or opts.rules_enable == "1") and true or false,
|
||||
proto_filter = util.trim(opts.proto_filter or ""),
|
||||
keyword_include = util.trim(opts.keyword_include or ""),
|
||||
keyword_exclude = util.trim(opts.keyword_exclude or ""),
|
||||
dedup = (opts.dedup == true or opts.dedup == "1") and "1" or "0",
|
||||
rename_map = opts.rename_map or "",
|
||||
["local"] = false,
|
||||
raw_content = "",
|
||||
local_mode = "text",
|
||||
}
|
||||
if not save(seq, items) then return nil, "写入失败" end
|
||||
return id
|
||||
end
|
||||
|
||||
function M.add_local(name, raw_content, local_mode, opts)
|
||||
name = util.trim(name or "")
|
||||
raw_content = util.trim(raw_content or "")
|
||||
local_mode = local_mode or "text"
|
||||
opts = opts or {}
|
||||
if name == "" or raw_content == "" then return nil, "名称/内容 不能为空" end
|
||||
local seq, items = load()
|
||||
seq = seq + 1
|
||||
local id = string.format("s%08x", seq)
|
||||
items[id] = {
|
||||
name = name, url = "", enabled = true,
|
||||
node_count = 0, last_update = nil, error = "", format = "",
|
||||
token = util.rnd_hex(16),
|
||||
proxy_enable = "0", proxy = "",
|
||||
cron_enable = false, cron_time = "",
|
||||
rules_enable = (opts.rules_enable == true or opts.rules_enable == "1") and true or false,
|
||||
proto_filter = util.trim(opts.proto_filter or ""),
|
||||
keyword_include = util.trim(opts.keyword_include or ""),
|
||||
keyword_exclude = util.trim(opts.keyword_exclude or ""),
|
||||
dedup = (opts.dedup == true or opts.dedup == "1") and "1" or "0",
|
||||
rename_map = opts.rename_map or "",
|
||||
["local"] = true,
|
||||
raw_content = raw_content,
|
||||
local_mode = local_mode,
|
||||
}
|
||||
if not save(seq, items) then return nil, "写入失败" end
|
||||
-- 立即解析一次
|
||||
M.sync(id)
|
||||
return id
|
||||
end
|
||||
|
||||
-- 获取订阅的下载 token;若不存在则生成并持久化
|
||||
function M.ensure_token(id)
|
||||
if not id_is_valid(id) then return nil end
|
||||
local seq, items = load()
|
||||
local meta = items[id]
|
||||
if not meta then return nil end
|
||||
if not meta.token or meta.token == "" then
|
||||
meta.token = util.rnd_hex(16)
|
||||
save(seq, items)
|
||||
end
|
||||
return meta.token
|
||||
end
|
||||
|
||||
-- 依据 token 查找订阅 ID
|
||||
local function id_by_token(token)
|
||||
if type(token) ~= "string" or token == "" then return nil end
|
||||
local _, items = load()
|
||||
for id, meta in pairs(items) do
|
||||
if meta.token == token then return id end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- 生成订阅下载内容:按 target 格式转换节点。返回 content, content_type, filename, err
|
||||
function M.generate_link(token, target, opts)
|
||||
opts = opts or {}
|
||||
local id = id_by_token(token)
|
||||
if not id then return nil, nil, nil, "无效的订阅 token" end
|
||||
local meta = M.get(id)
|
||||
local nodes = M.read_nodes(id)
|
||||
if #nodes == 0 then return nil, nil, nil, "暂无可用的节点(请先更新订阅)" end
|
||||
|
||||
local output = require("substore.output")
|
||||
local content, err = output.generate(nodes, target, opts)
|
||||
if not content then return nil, nil, nil, err or "无法生成目标格式" end
|
||||
|
||||
local ct = opts.content_type or output.content_type_for(target) or "text/plain; charset=utf-8"
|
||||
local ext = output.extension_for(target) or "txt"
|
||||
local base = (meta and meta.name and meta.name ~= "") and meta.name or id
|
||||
local filename = base .. "." .. ext
|
||||
return content, ct, filename, nil
|
||||
end
|
||||
|
||||
function M.save_meta(id, patch)
|
||||
if not id_is_valid(id) then return false, "非法 ID" end
|
||||
local seq, items = load()
|
||||
local meta = items[id]
|
||||
if not meta then return false, "订阅不存在" end
|
||||
for k, v in pairs(patch or {}) do
|
||||
if v == nil then meta[k] = nil else meta[k] = v end
|
||||
end
|
||||
return save(seq, items)
|
||||
end
|
||||
|
||||
function M.remove(id)
|
||||
if not id_is_valid(id) then return false end
|
||||
local seq, items = load()
|
||||
if not items[id] then return false end
|
||||
items[id] = nil
|
||||
save(seq, items)
|
||||
os.remove(M.nodes_file(id))
|
||||
return true
|
||||
end
|
||||
|
||||
function M.nodes_file(id)
|
||||
return M.NODES_DIR .. "/" .. id .. ".json"
|
||||
end
|
||||
|
||||
function M.write_nodes(id, nodes)
|
||||
M.ensure_dirs()
|
||||
return util.atomic_write(M.nodes_file(id), util.json_encode(nodes))
|
||||
end
|
||||
|
||||
function M.read_nodes(id)
|
||||
if not id_is_valid(id) then return {} end
|
||||
local raw = util.read_file(M.nodes_file(id))
|
||||
if not raw or raw == "" then return {} end
|
||||
local nodes = util.json_decode(raw)
|
||||
if type(nodes) ~= "table" then return {} end
|
||||
return nodes
|
||||
end
|
||||
|
||||
-- 解析 subscription-userinfo 响应头(upload/download/total/expire),返回数字表或 nil
|
||||
function M.parse_userinfo(s)
|
||||
if type(s) ~= "string" or s == "" then return nil end
|
||||
local u = {}
|
||||
for k, v in s:gmatch("([%w_%-]+)%s*=%s*([^;]+)") do
|
||||
local key = k:lower()
|
||||
if key == "upload" or key == "download" or key == "total" or key == "expire" then
|
||||
local num = tonumber(util.trim(v))
|
||||
if num then u[key] = num end
|
||||
end
|
||||
end
|
||||
if next(u) == nil then return nil end
|
||||
return u
|
||||
end
|
||||
|
||||
-- 下载并解析订阅,写入节点文件并更新状态。成功返回 node_count,失败返回 nil, err
|
||||
function M.sync(id)
|
||||
local log = function(msg) os.execute("logger -t luci-app-substore " .. string.format("%q", msg)) end
|
||||
log("Sync start id="..tostring(id))
|
||||
local meta = M.get(id)
|
||||
if not meta then log("Sync fail: subscription not found"); return nil, "订阅不存在" end
|
||||
-- 组合订阅:无下载源,直接重算合并节点
|
||||
if M.is_combo(meta) then
|
||||
log("Sync combo refresh")
|
||||
local cnt, cerr = M.combo_refresh(id)
|
||||
if not cnt then log("Combo refresh fail: " .. tostring(cerr)) end
|
||||
return cnt, cerr
|
||||
end
|
||||
-- 本地订阅:直接解析 raw_content
|
||||
if meta["local"] then
|
||||
log("Sync local subscription")
|
||||
local content = meta.raw_content or ""
|
||||
if content == "" then
|
||||
M.save_meta(id, { error = "本地订阅内容为空", node_count = 0, last_update = os.time() })
|
||||
return nil, "本地订阅内容为空"
|
||||
end
|
||||
local res, perr = parser.parse_local(content, meta.local_mode or "text")
|
||||
if not res or not res.nodes then
|
||||
log("Parse local fail: " .. tostring(perr))
|
||||
M.save_meta(id, { error = perr or "本地解析失败", node_count = 0, last_update = os.time() })
|
||||
return nil, perr or "本地解析失败"
|
||||
end
|
||||
log("Parse local ok nodes="..#res.nodes)
|
||||
local nodes = M.apply_rules(res.nodes, meta)
|
||||
if not M.write_nodes(id, nodes) then
|
||||
M.save_meta(id, { error = "写入节点数据失败", last_update = os.time() })
|
||||
return nil, "写入节点数据失败"
|
||||
end
|
||||
local ok = M.save_meta(id, {
|
||||
node_count = #nodes, format = res.format or "local", error = "", last_update = os.time(),
|
||||
})
|
||||
if not ok then return nil, "更新状态失败" end
|
||||
M.refresh_combos(id)
|
||||
return #nodes
|
||||
end
|
||||
if not meta.url or meta.url == "" then log("Sync fail: no URL"); return nil, "无订阅 URL" end
|
||||
|
||||
-- 订阅代理:开启且代理地址有效时,通过代理下载订阅
|
||||
local proxy = ""
|
||||
if meta.proxy_enable == true or meta.proxy_enable == "1" then
|
||||
local p, perr = http.parse_proxy(meta.proxy or "")
|
||||
if p and p ~= "" then
|
||||
proxy = p
|
||||
elseif perr then
|
||||
log("Proxy ignored: " .. tostring(perr))
|
||||
end
|
||||
end
|
||||
if proxy ~= "" then log("Using proxy " .. proxy) end
|
||||
|
||||
local content, headers, err = http.download(meta.url, { max_size = M.MAX_SIZE, timeout = M.TIMEOUT, proxy = proxy })
|
||||
if not content then
|
||||
log("Download fail: " .. tostring(err))
|
||||
M.save_meta(id, { error = err, last_update = os.time() })
|
||||
return nil, err
|
||||
end
|
||||
log("Download ok size="..#content)
|
||||
|
||||
local ui = M.parse_userinfo(headers and headers["subscription-userinfo"])
|
||||
if ui then log("Userinfo total="..tostring(ui.total).." expire="..tostring(ui.expire)) end
|
||||
|
||||
local res, perr = parser.parse(content)
|
||||
if not res or not res.nodes then
|
||||
log("Parse fail: " .. tostring(perr))
|
||||
M.save_meta(id, { error = perr or "解析失败", node_count = 0, last_update = os.time() })
|
||||
return nil, perr or "解析失败"
|
||||
end
|
||||
log("Parse ok nodes="..#res.nodes)
|
||||
|
||||
local nodes = M.apply_rules(res.nodes, meta)
|
||||
if not M.write_nodes(id, nodes) then
|
||||
M.save_meta(id, { error = "写入节点数据失败", last_update = os.time() })
|
||||
return nil, "写入节点数据失败"
|
||||
end
|
||||
|
||||
local ok = M.save_meta(id, {
|
||||
node_count = #nodes, format = res.format, error = "", last_update = os.time(),
|
||||
upload = ui and ui.upload, download = ui and ui.download, total = ui and ui.total, expire = ui and ui.expire,
|
||||
})
|
||||
if not ok then return nil, "更新状态失败" end
|
||||
-- 源订阅更新后,刷新引用它的组合订阅
|
||||
M.refresh_combos(id)
|
||||
return #nodes
|
||||
end
|
||||
|
||||
-- 对节点应用订阅级规则(rules_enable 为真时生效);纯函数,便于测试
|
||||
function M.apply_rules(nodes, meta)
|
||||
meta = meta or {}
|
||||
if not (meta.rules_enable == true or meta.rules_enable == "1") then return nodes end
|
||||
local node_mod = require("substore.node")
|
||||
local rules = {
|
||||
proto_filter = meta.proto_filter or "",
|
||||
keyword_include = meta.keyword_include or "",
|
||||
keyword_exclude = meta.keyword_exclude or "",
|
||||
dedup = meta.dedup or "0",
|
||||
rename_map = meta.rename_map or "",
|
||||
}
|
||||
return node_mod.apply_rules(nodes, rules)
|
||||
end
|
||||
|
||||
-- ---------- 单节点编辑 ----------
|
||||
|
||||
-- 表单管理的字段集合:合并时先从原节点清除,再用表单值覆盖,未出现在表单中的字段(raw/tags 等)保留
|
||||
local FORM_KEYS = {
|
||||
name = true, group = true, server = true, port = true, password = true,
|
||||
cipher = true, method = true, protocol = true, obfs = true,
|
||||
["obfs-param"] = true, obfs_param = true, ["protocol-param"] = true, protocol_param = true,
|
||||
udp = true, uuid = true, alterId = true, net = true, network = true,
|
||||
headerType = true, path = true, host = true, sni = true, tls = true,
|
||||
["skip-cert-verify"] = true, skip_cert_verify = true, security = true, flow = true,
|
||||
["obfs-password"] = true, obfs_password = true,
|
||||
["private-key"] = true, private_key = true, ["peer-public-key"] = true, peer_public_key = true,
|
||||
}
|
||||
|
||||
-- 合并表单节点到原节点:表单字段整体替换(可清空),非表单字段保留
|
||||
function M.merge_form_node(orig, formnode)
|
||||
local out = {}
|
||||
for k, v in pairs(orig or {}) do out[k] = v end
|
||||
for k in pairs(FORM_KEYS) do out[k] = nil end
|
||||
for k, v in pairs(formnode or {}) do
|
||||
if k ~= "type" then out[k] = v end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- ---------- 组合订阅(combo) ----------
|
||||
|
||||
-- 判断是否为组合订阅
|
||||
function M.is_combo(meta)
|
||||
return meta ~= nil and (meta.combo == true or type(meta.sources) == "table")
|
||||
end
|
||||
|
||||
-- 物化组合节点:按 sources 顺序合并各源节点,再应用组合自身的规则(复用 per-sub 规则字段)
|
||||
function M.combo_nodes(meta)
|
||||
if type(meta) ~= "table" then return {} end
|
||||
local srcs = type(meta.sources) == "table" and meta.sources or {}
|
||||
local merged = {}
|
||||
for _, sid in ipairs(srcs) do
|
||||
local ns = M.read_nodes(sid)
|
||||
for _, n in ipairs(ns) do merged[#merged + 1] = n end
|
||||
end
|
||||
return M.apply_rules(merged, meta)
|
||||
end
|
||||
|
||||
-- 重新计算组合节点并落盘,更新状态。成功返回 node_count,失败返回 nil, err
|
||||
function M.combo_refresh(id)
|
||||
if not id_is_valid(id) then return nil, "非法 ID" end
|
||||
local meta = M.get(id)
|
||||
if not meta then return nil, "订阅不存在" end
|
||||
local srcs = type(meta.sources) == "table" and meta.sources or {}
|
||||
if #srcs == 0 then
|
||||
M.save_meta(id, { error = "请选择至少一个订阅", node_count = 0, last_update = os.time() })
|
||||
return nil, "请选择至少一个订阅"
|
||||
end
|
||||
local nodes = M.combo_nodes(meta)
|
||||
if not M.write_nodes(id, nodes) then
|
||||
M.save_meta(id, { error = "写入节点数据失败", last_update = os.time() })
|
||||
return nil, "写入节点数据失败"
|
||||
end
|
||||
M.save_meta(id, { node_count = #nodes, error = "", last_update = os.time() })
|
||||
return #nodes
|
||||
end
|
||||
|
||||
-- 源订阅更新后刷新所有引用它的组合,避免组合停留旧数据
|
||||
function M.refresh_combos(src_id)
|
||||
if not src_id then return end
|
||||
for _, it in ipairs(M.list()) do
|
||||
local srcs = type(it.sources) == "table" and it.sources or {}
|
||||
for _, sid in ipairs(srcs) do
|
||||
if sid == src_id then
|
||||
M.combo_refresh(it.id)
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- 创建组合订阅(无 URL,cron/代理禁用);成功后物化节点。返回 id 或 nil, err
|
||||
function M.add_combo(name, sources, opts)
|
||||
name = util.trim(name or "")
|
||||
opts = opts or {}
|
||||
if name == "" then return nil, "名称不能为空" end
|
||||
local srcs = {}
|
||||
if type(sources) == "table" then
|
||||
for _, s in ipairs(sources) do
|
||||
s = util.trim(tostring(s or ""))
|
||||
if s ~= "" and id_is_valid(s) then srcs[#srcs + 1] = s end
|
||||
end
|
||||
end
|
||||
if #srcs == 0 then return nil, "请选择至少一个订阅" end
|
||||
local seq, items = load()
|
||||
seq = seq + 1
|
||||
local id = string.format("s%08x", seq)
|
||||
items[id] = {
|
||||
name = name, url = "", enabled = true, combo = true, sources = srcs,
|
||||
node_count = 0, last_update = nil, error = "", format = "",
|
||||
token = util.rnd_hex(16),
|
||||
proxy_enable = "0", proxy = "",
|
||||
cron_enable = false, cron_time = "",
|
||||
rules_enable = (opts.rules_enable == true or opts.rules_enable == "1") and true or false,
|
||||
proto_filter = util.trim(opts.proto_filter or ""),
|
||||
keyword_include = util.trim(opts.keyword_include or ""),
|
||||
keyword_exclude = util.trim(opts.keyword_exclude or ""),
|
||||
dedup = (opts.dedup == true or opts.dedup == "1") and "1" or "0",
|
||||
rename_map = opts.rename_map or "",
|
||||
}
|
||||
if not save(seq, items) then return nil, "写入失败" end
|
||||
M.combo_refresh(id)
|
||||
return id
|
||||
end
|
||||
|
||||
-- 编辑组合订阅:更新名称/来源/规则后重算物化节点。成功返回 node_count,失败返回 nil, err
|
||||
function M.save_combo(id, name, sources, opts)
|
||||
if not id_is_valid(id) then return nil, "非法 ID" end
|
||||
name = util.trim(name or "")
|
||||
if name == "" then return nil, "名称不能为空" end
|
||||
local srcs = {}
|
||||
if type(sources) == "table" then
|
||||
for _, s in ipairs(sources) do
|
||||
s = util.trim(tostring(s or ""))
|
||||
if s ~= "" and id_is_valid(s) and s ~= id then srcs[#srcs + 1] = s end
|
||||
end
|
||||
end
|
||||
if #srcs == 0 then return nil, "请选择至少一个订阅" end
|
||||
opts = opts or {}
|
||||
M.save_meta(id, {
|
||||
name = name, sources = srcs,
|
||||
rules_enable = (opts.rules_enable == true or opts.rules_enable == "1") and true or false,
|
||||
proto_filter = util.trim(opts.proto_filter or ""),
|
||||
keyword_include = util.trim(opts.keyword_include or ""),
|
||||
keyword_exclude = util.trim(opts.keyword_exclude or ""),
|
||||
dedup = (opts.dedup == true or opts.dedup == "1") and "1" or "0",
|
||||
rename_map = opts.rename_map or "",
|
||||
})
|
||||
return M.combo_refresh(id)
|
||||
end
|
||||
|
||||
-- 合并多个订阅的节点
|
||||
function M.merge(ids, opts)
|
||||
opts = opts or {}
|
||||
local all = {}
|
||||
local node_mod = require("substore.node")
|
||||
for _, id in ipairs(ids) do
|
||||
local nodes = M.read_nodes(id)
|
||||
for _, n in ipairs(nodes) do
|
||||
all[#all + 1] = n
|
||||
end
|
||||
end
|
||||
-- 规则已在各订阅 sync 时按订阅级配置应用;此处仅应用 opts 过滤
|
||||
if opts.proto then
|
||||
all = node_mod.filter(all, { proto = opts.proto })
|
||||
end
|
||||
if opts.keyword and opts.keyword ~= "" then
|
||||
all = node_mod.filter(all, { keyword = opts.keyword })
|
||||
end
|
||||
if opts.dedup then
|
||||
all = node_mod.dedup(all)
|
||||
end
|
||||
if opts.sort then
|
||||
all = node_mod.sort(all, opts.sort, opts.desc)
|
||||
end
|
||||
return all
|
||||
end
|
||||
|
||||
-- 校验 cron 表达式:5 个字段,每个为数字或 *,防 cron 文件命令注入
|
||||
function M.cron_time_valid(ct)
|
||||
if type(ct) ~= "string" then return false end
|
||||
local fields = {}
|
||||
for f in ct:gmatch("%S+") do fields[#fields + 1] = f end
|
||||
if #fields ~= 5 then return false end
|
||||
for _, f in ipairs(fields) do
|
||||
if f ~= "*" and not f:match("^%d+$") then return false end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
-- 依据各订阅的 cron 设置生成 /etc/cron.d/substore;无启用项则移除该文件
|
||||
function M.write_cron()
|
||||
local lines = { "# luci-app-substore cron (per-subscription)" }
|
||||
for _, it in ipairs(M.list()) do
|
||||
local en = (it.cron_enable == true or it.cron_enable == "1")
|
||||
local ct = util.trim(it.cron_time or "")
|
||||
if en and M.cron_time_valid(ct) then
|
||||
lines[#lines + 1] = ct .. " root /usr/bin/substore-cron.sh " .. it.id .. " >/tmp/substore-cron.log 2>&1"
|
||||
end
|
||||
end
|
||||
if #lines == 1 then
|
||||
os.remove(M.CRON_FILE)
|
||||
return true
|
||||
end
|
||||
return util.atomic_write(M.CRON_FILE, table.concat(lines, "\n") .. "\n")
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -0,0 +1,298 @@
|
||||
-- http.lua — 订阅下载(SSRF 防护、超时、大小限制)(纯 Lua)
|
||||
-- luci-app-substore
|
||||
|
||||
local util = require("substore.util")
|
||||
|
||||
local M = {}
|
||||
|
||||
M.DEFAULT_MAX_SIZE = 10 * 1024 * 1024 -- 10MB
|
||||
M.DEFAULT_TIMEOUT = 20
|
||||
M.MAX_REDIRECTS = 4
|
||||
|
||||
-- ---------- 私网 / 保留地址判断 ----------
|
||||
local function is_private_ipv4(ip)
|
||||
local a, b, c, d = ip:match("^(%d+)%.(%d+)%.(%d+)%.(%d+)$")
|
||||
if not a then return false end
|
||||
a, b, c, d = tonumber(a), tonumber(b), tonumber(c), tonumber(d)
|
||||
if not (a and b and c and d) then return true end
|
||||
if a == 0 then return true end
|
||||
if a == 10 then return true end
|
||||
if a == 127 then return true end
|
||||
if a == 100 and b >= 64 and b <= 127 then return true end -- 100.64/10 CGNAT
|
||||
if a == 169 and b == 254 then return true end -- link-local
|
||||
if a == 172 and b >= 16 and b <= 31 then return true end -- 172.16/12
|
||||
if a == 192 and b == 168 then return true end -- 192.168/16
|
||||
if a >= 224 then return true end -- multicast + reserved
|
||||
return false
|
||||
end
|
||||
|
||||
local function is_private_ipv6(ip)
|
||||
if ip == "::" or ip == "::1" then return true end
|
||||
local l = ip:lower()
|
||||
if l:match("^0*:") and not l:match("^0*::?0*$") and l ~= "0" then return true end
|
||||
if l:sub(1, 2) == "fc" or l:sub(1, 2) == "fd" then return true end -- ULA fc00/7
|
||||
local ll = l:sub(1, 4)
|
||||
if ll == "fe80" or ll == "fe81" or ll == "fe82" or ll == "fe83"
|
||||
or ll == "fe84" or ll == "fe85" or ll == "fe86" or ll == "fe87"
|
||||
or ll == "fe88" or ll == "fe89" or ll == "fe8a" or ll == "fe8b"
|
||||
or ll == "fe8c" or ll == "fe8d" or ll == "fe8e" or ll == "fe8f"
|
||||
or ll == "fe90" or ll == "fe91" or ll == "fe92" or ll == "fe93"
|
||||
or ll == "fe94" or ll == "fe95" or ll == "fe96" or ll == "fe97"
|
||||
or ll == "fe98" or ll == "fe99" or ll == "fe9a" or ll == "fe9b"
|
||||
or ll == "fe9c" or ll == "fe9d" or ll == "fe9e" or ll == "fe9f"
|
||||
or ll == "fea0" or ll == "fea1" or ll == "fea2" or ll == "fea3"
|
||||
or ll == "fea4" or ll == "fea5" or ll == "fea6" or ll == "fea7"
|
||||
or ll == "fea8" or ll == "fea9" or ll == "feaa" or ll == "feab"
|
||||
or ll == "feac" or ll == "fead" or ll == "feae" or ll == "feaf"
|
||||
or ll == "feb0" or ll == "feb1" or ll == "feb2" or ll == "feb3"
|
||||
or ll == "feb4" or ll == "feb5" or ll == "feb6" or ll == "feb7"
|
||||
or ll == "feb8" or ll == "feb9" or ll == "feba" or ll == "febb"
|
||||
or ll == "febc" or ll == "febd" or ll == "febe" or ll == "febf" then
|
||||
return true -- link-local fe80::/10
|
||||
end
|
||||
if l:sub(1, 2) == "ff" then return true end -- multicast
|
||||
return false
|
||||
end
|
||||
|
||||
local function is_private(ip)
|
||||
ip = ip or ""
|
||||
if ip:find(":", 1, true) then return is_private_ipv6(ip) end
|
||||
return is_private_ipv4(ip)
|
||||
end
|
||||
|
||||
-- ---------- URL 解析 ----------
|
||||
function M.parse_url(url)
|
||||
url = util.trim(url or "")
|
||||
local scheme, rest = url:match("^([%w%+%-%.]+)://(.*)$")
|
||||
if not scheme then return nil, "无效 URL" end
|
||||
scheme = scheme:lower()
|
||||
if scheme ~= "http" and scheme ~= "https" then return nil, "仅支持 http/https" end
|
||||
local hostport = rest:gsub("/.*$", "")
|
||||
local host, port
|
||||
if hostport:sub(1, 1) == "[" then
|
||||
local close = hostport:find("]", 1, true)
|
||||
if not close then return nil, "无效主机名" end
|
||||
host = hostport:sub(2, close - 1)
|
||||
port = hostport:sub(close + 1):match("^:(%d+)$") or (scheme == "https" and "443" or "80")
|
||||
else
|
||||
local h, p = hostport:match("^([^:]+):(%d+)$")
|
||||
if h then
|
||||
host, port = h, p
|
||||
else
|
||||
host, port = hostport, (scheme == "https" and "443" or "80")
|
||||
end
|
||||
end
|
||||
if not host or host == "" then return nil, "无效主机名" end
|
||||
return { scheme = scheme, host = host, port = port }
|
||||
end
|
||||
|
||||
-- 校验并规范化代理地址:支持 http/https/socks4/socks5/socks5h,可含 user:pass@
|
||||
-- 空串 → ""(未使用);合法 → 规范化地址;非法 → nil, err
|
||||
function M.parse_proxy(p)
|
||||
local s = util.trim(p or "")
|
||||
if s == "" then return "" end
|
||||
local scheme, rest = s:match("^(%a[%w]*)://(.*)$")
|
||||
if not scheme then return nil, "代理格式无效" end
|
||||
scheme = scheme:lower()
|
||||
if scheme ~= "http" and scheme ~= "https" and scheme ~= "socks4"
|
||||
and scheme ~= "socks5" and scheme ~= "socks5h" then
|
||||
return nil, "不支持的代理协议: " .. scheme
|
||||
end
|
||||
local userinfo, hostport = "", rest:gsub("/.*$", "")
|
||||
local at = hostport:find("@", 1, true)
|
||||
if at then
|
||||
userinfo = hostport:sub(1, at - 1)
|
||||
hostport = hostport:sub(at + 1)
|
||||
end
|
||||
if userinfo ~= "" and not userinfo:match("^[%w%.%-_:]+$") then
|
||||
return nil, "代理用户名/密码含非法字符"
|
||||
end
|
||||
local host, port
|
||||
if hostport:sub(1, 1) == "[" then
|
||||
host = hostport:match("^%[([%w%.%-%:]+)%]")
|
||||
port = hostport:match("^%[.*%]%:(%d+)$")
|
||||
else
|
||||
local h, prt = hostport:match("^([^:]+):(%d+)$")
|
||||
if h then host, port = h, prt else host = hostport end
|
||||
end
|
||||
if not host or host == "" or not host:match("^[%w%.%-%:]+$") then
|
||||
return nil, "代理主机无效"
|
||||
end
|
||||
if port then
|
||||
port = tonumber(port)
|
||||
if not port or port < 1 or port > 65535 then return nil, "代理端口无效" end
|
||||
end
|
||||
local r = scheme .. "://"
|
||||
if userinfo ~= "" then r = r .. userinfo .. "@" end
|
||||
r = r .. host
|
||||
if port then r = r .. ":" .. port end
|
||||
return r
|
||||
end
|
||||
|
||||
-- 解析主机 → IP 列表;无法解析时返回 nil
|
||||
local function resolve(host)
|
||||
local nixio = util.try_require("nixio")
|
||||
if not nixio then return nil end
|
||||
local ok, addrs = pcall(function() return nixio.getaddrinfo(host) end)
|
||||
if not ok or type(addrs) ~= "table" then return nil end
|
||||
local ips = {}
|
||||
for _, a in ipairs(addrs) do
|
||||
if a and a.addr then ips[#ips + 1] = a.addr end
|
||||
end
|
||||
if #ips == 0 then return nil end
|
||||
return ips
|
||||
end
|
||||
|
||||
-- SSRF 预检:拒绝 localhost / 私网 / 保留地址。返回 ok, reason
|
||||
function M.check_public(host)
|
||||
host = util.trim(host or "")
|
||||
if host == "" then return false, "空主机名" end
|
||||
if host:match("^%d+%.%d+%.%d+%.%d+$") or host:find(":", 1, true) then
|
||||
if is_private(host) then return false, "目标为内网/保留地址" end
|
||||
return true
|
||||
end
|
||||
if host:lower() == "localhost" then return false, "目标为 localhost" end
|
||||
local ips = resolve(host)
|
||||
if not ips then
|
||||
-- 无 DNS 解析能力:放行,交由下载工具处理(尽力而为)
|
||||
return true
|
||||
end
|
||||
for _, ip in ipairs(ips) do
|
||||
if not is_private(ip) then return true end
|
||||
end
|
||||
return false, "目标仅解析到内网/保留地址"
|
||||
end
|
||||
|
||||
-- ---------- 下载 ----------
|
||||
local function have(cmd)
|
||||
return os.execute("command -v " .. cmd .. " >/dev/null 2>&1") == 0
|
||||
end
|
||||
|
||||
local function detect_tool()
|
||||
if have("curl") then return "curl" end
|
||||
if have("wget") then return "wget" end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function location_from_headers(path)
|
||||
local raw = util.read_file(path)
|
||||
if not raw then return nil end
|
||||
for line in raw:gmatch("[^\r\n]+") do
|
||||
local v = line:match("^%s*[Ll]ocation:%s*(.+)$")
|
||||
if v then return util.trim(v) end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- 读取响应头(curl -D 输出),返回小写键 → 值的表;重复键取最后一个
|
||||
local function read_headers(path)
|
||||
local h = {}
|
||||
local raw = util.read_file(path)
|
||||
if not raw then return h end
|
||||
for line in raw:gmatch("[^\r\n]+") do
|
||||
local k, v = line:match("^%s*([^:]+):%s*(.*)$")
|
||||
if k then h[k:lower()] = v end
|
||||
end
|
||||
return h
|
||||
end
|
||||
|
||||
local function resolve_url(base, loc)
|
||||
if loc:find("://", 1, true) then return loc end
|
||||
local scheme, host = base:match("^([%w]+)://([^/]+)")
|
||||
if not scheme then return loc end
|
||||
if loc:sub(1, 1) == "/" then return scheme .. "://" .. host .. loc end
|
||||
local base_path = base:match("^[%w]+://[^/]+(.*)$") or "/"
|
||||
local dir = base_path:match("^(.*)/[^/]*$") or ""
|
||||
return scheme .. "://" .. host .. dir .. "/" .. loc
|
||||
end
|
||||
|
||||
local function fetch_curl(url, parsed, opts)
|
||||
local max, t = opts.max_size, opts.timeout
|
||||
local proxy_arg = ""
|
||||
if opts.proxy and opts.proxy ~= "" then
|
||||
proxy_arg = string.format(" -x %q", opts.proxy)
|
||||
end
|
||||
local cur = url
|
||||
for redirect = 0, M.MAX_REDIRECTS do
|
||||
local tmp = "/tmp/substore_dl_" .. redirect .. ".tmp"
|
||||
local hdr = tmp .. ".hdr"
|
||||
local errf = tmp .. ".err"
|
||||
os.remove(tmp); os.remove(hdr); os.remove(errf)
|
||||
local cmd = string.format(
|
||||
"curl -sS -o %q --max-time %d --connect-timeout %d --max-redirs 0 --max-filesize %d -D %q -w \"%%{http_code}\"%s %q 2>%q",
|
||||
tmp, t, math.min(t, 10), max, hdr, proxy_arg, cur, errf)
|
||||
local p = io.popen(cmd)
|
||||
local code = p and p:read("*a") or ""
|
||||
if p then p:close() end
|
||||
code = util.trim(code)
|
||||
if code == "" or code == "000" then
|
||||
return nil, util.trim(util.read_file(errf) or "下载失败")
|
||||
end
|
||||
if code:match("^[23]%d%d$") then
|
||||
local size = util.file_size(tmp)
|
||||
if size > max then return nil, "响应超过大小限制 (" .. max .. " 字节)" end
|
||||
local content = util.read_file(tmp)
|
||||
local headers = read_headers(hdr)
|
||||
os.remove(tmp); os.remove(hdr); os.remove(errf)
|
||||
if not content then return nil, "读取响应失败" end
|
||||
return content, headers
|
||||
end
|
||||
if code:match("^3%d%d$") then
|
||||
local loc = location_from_headers(hdr)
|
||||
if not loc then return nil, "重定向无 Location" end
|
||||
local next_url = resolve_url(cur, loc)
|
||||
local np = M.parse_url(next_url)
|
||||
if not np then return nil, "重定向目标无效" end
|
||||
local ok, re = M.check_public(np.host)
|
||||
if not ok then return nil, "重定向目标不安全: " .. (re or "") end
|
||||
cur = next_url
|
||||
else
|
||||
return nil, "HTTP 错误 " .. code
|
||||
end
|
||||
end
|
||||
return nil, "重定向次数过多"
|
||||
end
|
||||
|
||||
local function fetch_wget(url, parsed, opts)
|
||||
local max, t = opts.max_size, opts.timeout
|
||||
local tmp = "/tmp/substore_dl_wget.tmp"
|
||||
os.remove(tmp)
|
||||
-- 仅 http/https 代理可用环境变量传递(busybox wget 不支持 socks 代理)
|
||||
local proxy_env = ""
|
||||
if opts.proxy and opts.proxy ~= "" and opts.proxy:match("^https?://") then
|
||||
proxy_env = string.format("http_proxy=%q https_proxy=%q ", opts.proxy, opts.proxy)
|
||||
end
|
||||
local cmd = proxy_env .. string.format("wget -q -T %d -O %q %q 2>/dev/null", t, tmp, url)
|
||||
os.execute(cmd)
|
||||
local size = util.file_size(tmp)
|
||||
if size > max then os.remove(tmp); return nil, "响应超过大小限制 (" .. max .. " 字节)" end
|
||||
if size == 0 then os.remove(tmp); return nil, "下载失败或内容为空" end
|
||||
local content = util.read_file(tmp)
|
||||
os.remove(tmp)
|
||||
if not content then return nil, "读取响应失败" end
|
||||
return content, {} -- wget 不捕获响应头(无 subscription-userinfo)
|
||||
end
|
||||
|
||||
local function fetch(tool, url, parsed, opts)
|
||||
if tool == "curl" then return fetch_curl(url, parsed, opts) end
|
||||
return fetch_wget(url, parsed, opts)
|
||||
end
|
||||
|
||||
-- 下载订阅内容。成功返回 body, headers, nil;失败返回 nil, nil, err
|
||||
-- opts.proxy 为可选代理地址(scheme://host:port),应由调用方用 parse_proxy 校验
|
||||
function M.download(url, opts)
|
||||
opts = opts or {}
|
||||
local max_size = opts.max_size or M.DEFAULT_MAX_SIZE
|
||||
local timeout = opts.timeout or M.DEFAULT_TIMEOUT
|
||||
local parsed = M.parse_url(url)
|
||||
if not parsed then return nil, nil, "无效 URL" end
|
||||
local ok, reason = M.check_public(parsed.host)
|
||||
if not ok then return nil, nil, reason end
|
||||
local tool = detect_tool()
|
||||
if not tool then return nil, nil, "无可用下载工具 (curl/wget)" end
|
||||
local body, headers = fetch(tool, url, parsed, { max_size = max_size, timeout = timeout, proxy = opts.proxy })
|
||||
if not body then return nil, nil, headers end
|
||||
return body, headers or {}, nil
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -0,0 +1,395 @@
|
||||
-- node.lua — 统一节点模型(纯 Lua)
|
||||
-- luci-app-substore
|
||||
|
||||
local M = {}
|
||||
|
||||
M.PROTOS = {
|
||||
"vmess", "vless", "trojan", "shadowsocks", "ssr", "hysteria2", "tuic", "hysteria", "wireguard", "socks",
|
||||
}
|
||||
|
||||
-- 协议默认值,用于补全缺省字段
|
||||
local DEFAULTS = {
|
||||
vmess = { net = "tcp", security = "none" },
|
||||
vless = { net = "tcp", security = "none" },
|
||||
trojan = { security = "tls" },
|
||||
}
|
||||
|
||||
-- 判断节点是否拥有某标签;兼容 tags 为数组({"fast"})或 set({fast=true})
|
||||
local function has_tag(node, tag)
|
||||
local t = node and node.tags
|
||||
if type(t) ~= "table" then return false end
|
||||
if t[tag] == true then return true end
|
||||
for _, v in ipairs(t) do
|
||||
if v == tag then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- 归一化:协议别名统一、补默认值、保证 name 非空
|
||||
function M.normalize(node)
|
||||
if type(node) ~= "table" then return node end
|
||||
if node.proto == "ss" then node.proto = "shadowsocks" end
|
||||
local d = DEFAULTS[node.proto] or {}
|
||||
for k, v in pairs(d) do
|
||||
if node[k] == nil then node[k] = v end
|
||||
end
|
||||
if node.name == nil or node.name == "" then
|
||||
node.name = (node.server or "") .. ":" .. tostring(node.port or "")
|
||||
end
|
||||
-- tags 字符串自动拆分为数组(逗号分隔)
|
||||
if type(node.tags) == "string" then
|
||||
local t = {}
|
||||
for tag in node.tags:gmatch("[^,]+") do
|
||||
tag = tag:match("^%s*(.-)%s*$")
|
||||
if tag ~= "" then t[#t + 1] = tag end
|
||||
end
|
||||
node.tags = t
|
||||
end
|
||||
-- 保持 group、remarks、template、url 字段原样
|
||||
return node
|
||||
end
|
||||
|
||||
-- 过滤:支持 proto、keyword(name/server)、server、port、group、tags
|
||||
function M.filter(nodes, opts)
|
||||
opts = opts or {}
|
||||
local out = {}
|
||||
for _, n in ipairs(nodes) do
|
||||
if opts.proto and n.proto ~= opts.proto then
|
||||
else
|
||||
local ok = true
|
||||
if opts.keyword and opts.keyword ~= "" then
|
||||
local kw = opts.keyword:lower()
|
||||
if not ( (n.name or ""):lower():find(kw, 1, true) or (n.server or ""):lower():find(kw, 1, true) ) then
|
||||
ok = false
|
||||
end
|
||||
end
|
||||
if ok and opts.server and n.server ~= opts.server then ok = false end
|
||||
if ok and opts.port and tonumber(n.port) ~= tonumber(opts.port) then ok = false end
|
||||
if ok and opts.group and n.group ~= opts.group then ok = false end
|
||||
if ok and opts.tags then
|
||||
local wanted = opts.tags
|
||||
if type(wanted) == "string" then wanted = wanted:gsub("^%s*(.-)%s*$", "%1") end
|
||||
if not has_tag(n, wanted) then ok = false end
|
||||
end
|
||||
if ok then out[#out + 1] = n end
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- 去重:按 proto+server+port 唯一
|
||||
function M.dedup(nodes)
|
||||
local seen = {}
|
||||
local out = {}
|
||||
for _, n in ipairs(nodes) do
|
||||
local key = (n.proto or "") .. "|" .. (n.server or "") .. "|" .. tostring(n.port or "")
|
||||
if not seen[key] then
|
||||
seen[key] = true
|
||||
out[#out + 1] = n
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- 排序:by = "name"|"server"|"proto"|"port"
|
||||
function M.sort(nodes, by, desc)
|
||||
by = by or "name"
|
||||
desc = desc and true or false
|
||||
table.sort(nodes, function(a, b)
|
||||
local av = a[by] or ""
|
||||
local bv = b[by] or ""
|
||||
if by == "port" then av, bv = tonumber(av) or 0, tonumber(bv) or 0 end
|
||||
if av == bv then return false end
|
||||
if desc then return av > bv else return av < bv end
|
||||
end)
|
||||
return nodes
|
||||
end
|
||||
|
||||
-- 重命名单个节点
|
||||
function M.rename(node, new_name)
|
||||
if type(node) == "table" and new_name then
|
||||
node.name = new_name
|
||||
end
|
||||
return node
|
||||
end
|
||||
|
||||
-- 将关键词串拆分为数组(英文逗号/中文逗号/空白分隔),忽略空项,并统一小写
|
||||
local function split_keywords(s)
|
||||
local out = {}
|
||||
for kw in (s or ""):gmatch("[^,%s,]+") do
|
||||
kw = kw:lower()
|
||||
if kw ~= "" then out[#out + 1] = kw end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- 判断节点 name/server 是否命中任一关键词
|
||||
local function match_keywords(n, kws)
|
||||
local name = (n.name or ""):lower()
|
||||
local server = (n.server or ""):lower()
|
||||
for _, kw in ipairs(kws) do
|
||||
if name:find(kw, 1, true) or server:find(kw, 1, true) then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- 应用规则集到节点列表
|
||||
function M.apply_rules(nodes, rules)
|
||||
rules = rules or {}
|
||||
-- 协议过滤
|
||||
if rules.proto_filter and rules.proto_filter ~= "" then
|
||||
local set = {}
|
||||
for p in rules.proto_filter:gmatch("[^,]+") do set[p]=true end
|
||||
local out = {}
|
||||
for _,n in ipairs(nodes) do
|
||||
if set[n.proto] then out[#out+1]=n end
|
||||
end
|
||||
nodes = out
|
||||
end
|
||||
-- 分组过滤
|
||||
if rules.group_filter and rules.group_filter ~= "" then
|
||||
local set = {}
|
||||
for g in rules.group_filter:gmatch("[^,]+") do set[g]=true end
|
||||
local out = {}
|
||||
for _,n in ipairs(nodes) do
|
||||
if set[n.group] then out[#out+1]=n end
|
||||
end
|
||||
nodes = out
|
||||
end
|
||||
-- 标签包含
|
||||
if rules.tags_include and rules.tags_include ~= "" then
|
||||
local set = {}
|
||||
for t in rules.tags_include:gmatch("[^,]+") do set[t]=true end
|
||||
local out = {}
|
||||
for _,n in ipairs(nodes) do
|
||||
local match = false
|
||||
for tag in pairs(set) do
|
||||
if has_tag(n, tag) then match = true; break end
|
||||
end
|
||||
if match then out[#out+1]=n end
|
||||
end
|
||||
nodes = out
|
||||
end
|
||||
-- 模板过滤
|
||||
if rules.template_filter and rules.template_filter ~= "" then
|
||||
local set = {}
|
||||
for tmpl in rules.template_filter:gmatch("[^,]+") do set[tmpl]=true end
|
||||
local out = {}
|
||||
for _,n in ipairs(nodes) do
|
||||
if set[n.template] then out[#out+1]=n end
|
||||
end
|
||||
nodes = out
|
||||
end
|
||||
-- 关键词包含
|
||||
if rules.keyword_include and rules.keyword_include ~= "" then
|
||||
local kws = split_keywords(rules.keyword_include)
|
||||
local out = {}
|
||||
for _,n in ipairs(nodes) do
|
||||
if match_keywords(n, kws) then out[#out+1]=n end
|
||||
end
|
||||
nodes = out
|
||||
end
|
||||
-- 关键词排除
|
||||
if rules.keyword_exclude and rules.keyword_exclude ~= "" then
|
||||
local kws = split_keywords(rules.keyword_exclude)
|
||||
local out = {}
|
||||
for _,n in ipairs(nodes) do
|
||||
if not match_keywords(n, kws) then out[#out+1]=n end
|
||||
end
|
||||
nodes = out
|
||||
end
|
||||
-- 去重
|
||||
if rules.dedup == "1" or rules.dedup == true then
|
||||
nodes = M.dedup(nodes)
|
||||
end
|
||||
-- 重命名规则(精确匹配 / 正则 / 模板,统一走重命名引擎)
|
||||
if rules.rename_map and rules.rename_map ~= "" then
|
||||
nodes = M.rename_with_rules(nodes, M.parse_rename_rules(rules.rename_map))
|
||||
end
|
||||
-- 模板应用:若节点有 template,则生成 url(简单占位符替换)
|
||||
if rules.template_apply == true or rules.template_apply == "1" then
|
||||
for _,n in ipairs(nodes) do
|
||||
if n.template and n.template ~= "" and not n.url then
|
||||
local url = n.template
|
||||
url = url:gsub("{server}", n.server or "")
|
||||
url = url:gsub("{port}", tostring(n.port or ""))
|
||||
url = url:gsub("{uuid}", n.uuid or n.password or "")
|
||||
url = url:gsub("{name}", n.name or "")
|
||||
n.url = url
|
||||
end
|
||||
end
|
||||
end
|
||||
return nodes
|
||||
end
|
||||
|
||||
-- ---------- 分组 ----------
|
||||
|
||||
-- 按字段分组:返回 { [字段值] = {节点...} }
|
||||
function M.group_by(nodes, field)
|
||||
local groups = {}
|
||||
for _, n in ipairs(nodes) do
|
||||
local key = tostring(n[field] or "")
|
||||
if not groups[key] then groups[key] = {} end
|
||||
groups[key][#groups[key] + 1] = n
|
||||
end
|
||||
return groups
|
||||
end
|
||||
|
||||
-- 按自身 group 字段分组(缺省组 "")
|
||||
function M.group_nodes(nodes)
|
||||
return M.group_by(nodes, "group")
|
||||
end
|
||||
|
||||
-- 按规则为节点设置 group:rules = { {field=..., prefix=...}, ... }
|
||||
function M.add_group(nodes, rules)
|
||||
rules = rules or {}
|
||||
for _, n in ipairs(nodes) do
|
||||
for _, r in ipairs(rules) do
|
||||
if type(r) == "table" and r.field then
|
||||
n.group = (r.prefix or "") .. tostring(n[r.field] or "")
|
||||
end
|
||||
end
|
||||
end
|
||||
return nodes
|
||||
end
|
||||
|
||||
-- 按组名过滤
|
||||
function M.filter_by_group(nodes, group_name)
|
||||
local out = {}
|
||||
for _, n in ipairs(nodes) do
|
||||
if n.group == group_name then out[#out + 1] = n end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- ---------- 标签 ----------
|
||||
|
||||
-- 为节点打标签。两种模式:
|
||||
-- 数组模式:add_tags(nodes, {"vip"}) 追加字符串标签(tags 存为数组)
|
||||
-- 规则模式:add_tags(nodes, {{type="keyword", value="HK", tag="hongkong"}}) 匹配后写 tag(tags 存为 set)
|
||||
function M.add_tags(nodes, tags)
|
||||
tags = tags or {}
|
||||
if type(tags) == "string" then tags = { tags } end
|
||||
if type(tags) ~= "table" then return nodes end
|
||||
|
||||
-- 规则模式:元素为 {type, value, tag}
|
||||
if tags[1] and type(tags[1]) == "table" then
|
||||
for _, n in ipairs(nodes) do
|
||||
if not n.tags or type(n.tags) ~= "table" then n.tags = {} end
|
||||
for _, rule in ipairs(tags) do
|
||||
local matched = false
|
||||
if rule.type == "keyword" then
|
||||
local kw = rule.value or ""
|
||||
if kw ~= "" and ((n.name or ""):find(kw, 1, true) or (n.server or ""):find(kw, 1, true)) then
|
||||
matched = true
|
||||
end
|
||||
end
|
||||
if matched and rule.tag then
|
||||
n.tags[rule.tag] = true
|
||||
end
|
||||
end
|
||||
end
|
||||
return nodes
|
||||
end
|
||||
|
||||
-- 数组模式:追加字符串标签(去重)
|
||||
for _, n in ipairs(nodes) do
|
||||
if not n.tags then n.tags = {} end
|
||||
if type(n.tags) ~= "table" then n.tags = {} end
|
||||
local has = {}
|
||||
for _, t in ipairs(n.tags) do has[t] = true end
|
||||
for _, t in ipairs(tags) do
|
||||
if not has[t] then
|
||||
n.tags[#n.tags + 1] = t
|
||||
has[t] = true
|
||||
end
|
||||
end
|
||||
end
|
||||
return nodes
|
||||
end
|
||||
|
||||
-- 按标签集合过滤:要求节点拥有 tags 中的所有标签
|
||||
function M.filter_by_tags(nodes, tags)
|
||||
local out = {}
|
||||
for _, n in ipairs(nodes) do
|
||||
local match = true
|
||||
if type(tags) == "string" then tags = { tags } end
|
||||
for _, t in ipairs(tags) do
|
||||
if not has_tag(n, t) then match = false; break end
|
||||
end
|
||||
if match then out[#out + 1] = n end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- ---------- 重命名规则引擎 ----------
|
||||
|
||||
-- 解析重命名规则字符串为规则表列表
|
||||
-- 支持格式(每行一条,支持 # 注释):
|
||||
-- "旧名称=新名称"(精确匹配,type="exact")
|
||||
-- "pattern -> replacement"(正则替换,type="regex")
|
||||
-- "{server}_{port}_{proto}"(含 {var} 占位符,type="template")
|
||||
function M.parse_rename_rules(rule_str)
|
||||
rule_str = rule_str or ""
|
||||
local rules = {}
|
||||
for line in rule_str:gmatch("[^\r\n]+") do
|
||||
line = line:match("^%s*(.-)%s*$")
|
||||
if line ~= "" and not line:match("^#") then
|
||||
local pat, repl = line:match("^(.-)%s*%-%>%s*(.+)$")
|
||||
if pat then
|
||||
rules[#rules + 1] = { type = "regex", pattern = pat, replacement = repl }
|
||||
elseif line:find("{", 1, true) then
|
||||
rules[#rules + 1] = { type = "template", template = line }
|
||||
else
|
||||
local k, v = line:match("^([^=]+)=(.*)$")
|
||||
if k and v then
|
||||
rules[#rules + 1] = { type = "exact", old = k:match("^%s*(.-)%s*$"), new = v:match("^%s*(.-)%s*$") }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return rules
|
||||
end
|
||||
|
||||
-- 展开模板:替换 {var} 占位符
|
||||
local function expand_template(template, n)
|
||||
local out = template
|
||||
out = out:gsub("{server}", n.server or "")
|
||||
out = out:gsub("{port}", tostring(n.port or ""))
|
||||
out = out:gsub("{proto}", n.proto or "")
|
||||
out = out:gsub("{name}", n.name or "")
|
||||
out = out:gsub("{uuid}", n.uuid or "")
|
||||
out = out:gsub("{password}", n.password or "")
|
||||
out = out:gsub("{group}", n.group or "")
|
||||
return out
|
||||
end
|
||||
|
||||
-- 按规则链式重命名节点(就地修改)
|
||||
function M.rename_with_rules(nodes, rules)
|
||||
rules = rules or {}
|
||||
for _, n in ipairs(nodes) do
|
||||
for _, r in ipairs(rules) do
|
||||
if r.type == "exact" then
|
||||
if n.name == r.old then n.name = r.new end
|
||||
elseif r.type == "template" and r.template then
|
||||
n.name = expand_template(r.template, n)
|
||||
elseif r.type == "regex" then
|
||||
local name = n.name or ""
|
||||
-- 正则风格 → Lua pattern:\d -> %d、$1 -> %1
|
||||
local pat = (r.pattern or ""):gsub("\\", "%%")
|
||||
local repl = (r.replacement or ""):gsub("%$", "%%")
|
||||
local ok, result = pcall(function()
|
||||
return string.gsub(name, pat, repl)
|
||||
end)
|
||||
if ok then
|
||||
n.name = result
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return nodes
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -0,0 +1,315 @@
|
||||
-- node_converter.lua — 协议字段映射表(纯 Lua)
|
||||
-- luci-app-substore
|
||||
|
||||
local M = {}
|
||||
|
||||
-- 协议字段映射配置
|
||||
-- 格式:source_proto -> target_proto -> { source_field = target_field, ... }
|
||||
M.FIELD_MAPPINGS = {
|
||||
vmess = {
|
||||
vless = {
|
||||
server = "server",
|
||||
port = "port",
|
||||
uuid = "uuid",
|
||||
network = "network",
|
||||
security = "security",
|
||||
sni = "sni",
|
||||
fingerprint = "fingerprint",
|
||||
path = "path",
|
||||
host = "host",
|
||||
name = "name",
|
||||
},
|
||||
trojan = {
|
||||
server = "server",
|
||||
port = "port",
|
||||
uuid = "password",
|
||||
network = "network",
|
||||
security = "security",
|
||||
sni = "sni",
|
||||
fingerprint = "fingerprint",
|
||||
path = "path",
|
||||
host = "host",
|
||||
name = "name",
|
||||
},
|
||||
shadowsocks = {
|
||||
server = "server",
|
||||
port = "port",
|
||||
uuid = "password",
|
||||
network = "network",
|
||||
name = "name",
|
||||
},
|
||||
["sing-box"] = {
|
||||
server = "server",
|
||||
port = "port",
|
||||
uuid = "uuid",
|
||||
network = "network",
|
||||
security = "security",
|
||||
sni = "sni",
|
||||
name = "name",
|
||||
},
|
||||
clash = {
|
||||
server = "server",
|
||||
port = "port",
|
||||
uuid = "uuid",
|
||||
network = "network",
|
||||
security = "security",
|
||||
sni = "sni",
|
||||
name = "name",
|
||||
},
|
||||
},
|
||||
vless = {
|
||||
vmess = {
|
||||
server = "server",
|
||||
port = "port",
|
||||
uuid = "uuid",
|
||||
security = "security",
|
||||
sni = "sni",
|
||||
fingerprint = "fingerprint",
|
||||
network = "network",
|
||||
path = "path",
|
||||
host = "host",
|
||||
name = "name",
|
||||
},
|
||||
trojan = {
|
||||
server = "server",
|
||||
port = "port",
|
||||
uuid = "password",
|
||||
security = "security",
|
||||
sni = "sni",
|
||||
fingerprint = "fingerprint",
|
||||
network = "network",
|
||||
path = "path",
|
||||
host = "host",
|
||||
name = "name",
|
||||
},
|
||||
shadowsocks = {
|
||||
server = "server",
|
||||
port = "port",
|
||||
uuid = "password",
|
||||
network = "network",
|
||||
name = "name",
|
||||
},
|
||||
},
|
||||
trojan = {
|
||||
vmess = {
|
||||
server = "server",
|
||||
port = "port",
|
||||
password = "uuid",
|
||||
security = "security",
|
||||
sni = "sni",
|
||||
fingerprint = "fingerprint",
|
||||
network = "network",
|
||||
path = "path",
|
||||
host = "host",
|
||||
name = "name",
|
||||
},
|
||||
vless = {
|
||||
server = "server",
|
||||
port = "port",
|
||||
password = "uuid",
|
||||
security = "security",
|
||||
sni = "sni",
|
||||
fingerprint = "fingerprint",
|
||||
network = "network",
|
||||
path = "path",
|
||||
host = "host",
|
||||
name = "name",
|
||||
},
|
||||
shadowsocks = {
|
||||
server = "server",
|
||||
port = "port",
|
||||
password = "password",
|
||||
network = "network",
|
||||
name = "name",
|
||||
},
|
||||
},
|
||||
shadowsocks = {
|
||||
vmess = {
|
||||
server = "server",
|
||||
port = "port",
|
||||
password = "uuid",
|
||||
network = "network",
|
||||
name = "name",
|
||||
},
|
||||
vless = {
|
||||
server = "server",
|
||||
port = "port",
|
||||
password = "uuid",
|
||||
network = "network",
|
||||
name = "name",
|
||||
},
|
||||
trojan = {
|
||||
server = "server",
|
||||
port = "port",
|
||||
password = "password",
|
||||
network = "network",
|
||||
name = "name",
|
||||
},
|
||||
},
|
||||
hysteria2 = {
|
||||
["sing-box"] = {
|
||||
server = "server",
|
||||
port = "port",
|
||||
password = "password",
|
||||
name = "name",
|
||||
},
|
||||
clash = {
|
||||
server = "server",
|
||||
port = "port",
|
||||
password = "password",
|
||||
name = "name",
|
||||
},
|
||||
},
|
||||
tuic = {
|
||||
["sing-box"] = {
|
||||
server = "server",
|
||||
port = "port",
|
||||
uuid = "uuid",
|
||||
password = "password",
|
||||
name = "name",
|
||||
},
|
||||
clash = {
|
||||
server = "server",
|
||||
port = "port",
|
||||
uuid = "uuid",
|
||||
password = "password",
|
||||
name = "name",
|
||||
},
|
||||
},
|
||||
wireguard = {
|
||||
["sing-box"] = {
|
||||
server = "server",
|
||||
port = "port",
|
||||
private_key = "private_key",
|
||||
public_key = "public_key",
|
||||
name = "name",
|
||||
},
|
||||
clash = {
|
||||
server = "server",
|
||||
port = "port",
|
||||
private_key = "private_key",
|
||||
public_key = "public_key",
|
||||
name = "name",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
-- 目标协议默认字段
|
||||
M.DEFAULTS = {
|
||||
shadowsocks = {
|
||||
method = "aes-256-gcm",
|
||||
},
|
||||
}
|
||||
|
||||
-- 获取字段映射
|
||||
function M.get_field_mapping(source_proto, target_proto)
|
||||
if not source_proto or not target_proto then
|
||||
return nil
|
||||
end
|
||||
-- 标准化协议名称
|
||||
source_proto = source_proto:lower()
|
||||
target_proto = target_proto:lower()
|
||||
|
||||
-- 处理别名
|
||||
if source_proto == "ss" then source_proto = "shadowsocks" end
|
||||
if target_proto == "ss" then target_proto = "shadowsocks" end
|
||||
|
||||
local source_map = M.FIELD_MAPPINGS[source_proto]
|
||||
if not source_map then
|
||||
return nil
|
||||
end
|
||||
|
||||
local mapping = source_map[target_proto]
|
||||
return mapping
|
||||
end
|
||||
|
||||
-- 转换节点协议
|
||||
function M.convert(node, target_proto)
|
||||
if type(node) ~= "table" then
|
||||
return nil
|
||||
end
|
||||
|
||||
local source_proto = node.proto
|
||||
if not source_proto then
|
||||
return nil
|
||||
end
|
||||
|
||||
-- 标准化协议名称
|
||||
source_proto = source_proto:lower()
|
||||
target_proto = target_proto:lower()
|
||||
|
||||
if source_proto == "ss" then source_proto = "shadowsocks" end
|
||||
if target_proto == "ss" then target_proto = "shadowsocks" end
|
||||
|
||||
-- 如果目标协议与源协议相同,直接返回副本
|
||||
if source_proto == target_proto then
|
||||
local copy = {}
|
||||
for k, v in pairs(node) do
|
||||
copy[k] = v
|
||||
end
|
||||
return copy
|
||||
end
|
||||
|
||||
local mapping = M.get_field_mapping(source_proto, target_proto)
|
||||
if not mapping then
|
||||
return nil
|
||||
end
|
||||
|
||||
-- 创建新节点
|
||||
local new_node = {}
|
||||
|
||||
-- 根据映射转换字段
|
||||
for source_field, target_field in pairs(mapping) do
|
||||
if node[source_field] ~= nil then
|
||||
new_node[target_field] = node[source_field]
|
||||
end
|
||||
end
|
||||
|
||||
-- 设置协议
|
||||
new_node.proto = target_proto
|
||||
|
||||
-- 应用默认字段
|
||||
local defaults = M.DEFAULTS[target_proto]
|
||||
if defaults then
|
||||
for k, v in pairs(defaults) do
|
||||
if new_node[k] == nil then
|
||||
new_node[k] = v
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- 保留 name,如果不存在则生成
|
||||
if not new_node.name and node.name then
|
||||
new_node.name = node.name
|
||||
elseif not new_node.name then
|
||||
new_node.name = (node.server or "") .. ":" .. tostring(node.port or "")
|
||||
end
|
||||
|
||||
-- 保留 server 和 port(如果映射中没有)
|
||||
if not new_node.server and node.server then
|
||||
new_node.server = node.server
|
||||
end
|
||||
if not new_node.port and node.port then
|
||||
new_node.port = node.port
|
||||
end
|
||||
|
||||
return new_node
|
||||
end
|
||||
|
||||
-- 批量转换
|
||||
function M.convert_batch(nodes, target_proto)
|
||||
if type(nodes) ~= "table" then
|
||||
return {}
|
||||
end
|
||||
|
||||
local result = {}
|
||||
for _, node in ipairs(nodes) do
|
||||
local converted = M.convert(node, target_proto)
|
||||
if converted then
|
||||
result[#result + 1] = converted
|
||||
end
|
||||
end
|
||||
return result
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -0,0 +1,161 @@
|
||||
-- output.lua — 订阅输出统一分发(所有 13 种目标格式)
|
||||
-- luci-app-substore
|
||||
|
||||
local util = require("substore.util")
|
||||
local node = require("substore.node")
|
||||
local clash_meta = require("substore.output_clash_meta")
|
||||
local output_uri = require("substore.output_uri")
|
||||
local output_singbox = require("substore.output_singbox")
|
||||
local output_v2ray = require("substore.output_v2ray")
|
||||
local output_formats = require("substore.output_formats")
|
||||
|
||||
local M = {}
|
||||
|
||||
local function esc_yaml(s)
|
||||
s = tostring(s or "")
|
||||
s = s:gsub("\\", "\\\\"):gsub("\"", "\\\""):gsub("\n", "\\n")
|
||||
if s:find("[ :#{}[\],&*?|>'\"%@`]", 1, true) or s:match("^[-?]*:") then
|
||||
return '"' .. s .. '"'
|
||||
end
|
||||
return s
|
||||
end
|
||||
|
||||
function M.to_clash_yaml(nodes)
|
||||
local out = {}
|
||||
out[#out + 1] = "proxies:"
|
||||
for _, n in ipairs(nodes) do
|
||||
local proto = n.proto or "vmess"
|
||||
local name = esc_yaml(n.name or "")
|
||||
out[#out + 1] = "- name: " .. name
|
||||
out[#out + 1] = " type: " .. proto
|
||||
out[#out + 1] = " server: " .. esc_yaml(n.server or "")
|
||||
out[#out + 1] = " port: " .. tostring(n.port or 0)
|
||||
if n.uuid then out[#out + 1] = " uuid: " .. esc_yaml(n.uuid) end
|
||||
if n.password then out[#out + 1] = " password: " .. esc_yaml(n.password) end
|
||||
if n.method then out[#out + 1] = " cipher: " .. esc_yaml(n.method) end
|
||||
if n.net then out[#out + 1] = " network: " .. esc_yaml(n.net) end
|
||||
if n.security then out[#out + 1] = " tls: " .. tostring(n.security ~= "none") end
|
||||
if n.sni then out[#out + 1] = " sni: " .. esc_yaml(n.sni) end
|
||||
end
|
||||
out[#out + 1] = ""
|
||||
out[#out + 1] = "proxy-groups:"
|
||||
out[#out + 1] = "- name: ALL"
|
||||
out[#out + 1] = " type: select"
|
||||
out[#out + 1] = " proxies: [REJECT]"
|
||||
return table.concat(out, "\n")
|
||||
end
|
||||
|
||||
function M.to_json(nodes)
|
||||
return util.json_encode(nodes)
|
||||
end
|
||||
|
||||
function M.to_base64(nodes)
|
||||
local yaml = M.to_clash_yaml(nodes)
|
||||
return util.base64_encode(yaml)
|
||||
end
|
||||
|
||||
-- 目标格式别名映射(兼容 ?target=X 的各类写法)
|
||||
M.FORMAT_ALIASES = {
|
||||
clash = "clashmeta",
|
||||
yaml = "clashmeta",
|
||||
clashmeta = "clashmeta",
|
||||
mihomo = "clashmeta",
|
||||
stash = "stash",
|
||||
surge = "surge",
|
||||
surfboard = "surfboard",
|
||||
surgemac = "surgemac",
|
||||
loon = "loon",
|
||||
egern = "egern",
|
||||
shadowrocket = "shadowrocket",
|
||||
rocket = "shadowrocket",
|
||||
qx = "qx",
|
||||
quantumult = "qx",
|
||||
singbox = "singbox",
|
||||
sing_box = "singbox",
|
||||
["sing-box"] = "singbox",
|
||||
v2ray = "v2ray",
|
||||
v2rayuri = "v2rayuri",
|
||||
v2ray_uri = "v2rayuri",
|
||||
uri = "v2rayuri",
|
||||
plain = "plain",
|
||||
plainjson = "plain",
|
||||
json = "plain",
|
||||
base64 = "shadowrocket",
|
||||
}
|
||||
|
||||
-- 目标格式的 HTTP Content-Type
|
||||
local CONTENT_TYPES = {
|
||||
clashmeta = "text/plain; charset=utf-8",
|
||||
stash = "text/plain; charset=utf-8",
|
||||
surge = "text/plain; charset=utf-8",
|
||||
surfboard = "text/plain; charset=utf-8",
|
||||
surgemac = "text/plain; charset=utf-8",
|
||||
loon = "text/plain; charset=utf-8",
|
||||
egern = "text/plain; charset=utf-8",
|
||||
qx = "text/plain; charset=utf-8",
|
||||
shadowrocket = "text/plain; charset=utf-8",
|
||||
singbox = "application/json; charset=utf-8",
|
||||
v2ray = "application/json; charset=utf-8",
|
||||
v2rayuri = "text/plain; charset=utf-8",
|
||||
plain = "application/json; charset=utf-8",
|
||||
}
|
||||
|
||||
function M.content_type_for(format)
|
||||
format = (format or "clash") or "clash"
|
||||
if type(format) ~= "string" then format = "clash" end
|
||||
local norm = M.FORMAT_ALIASES[format:lower():gsub("[-_%s]", "")]
|
||||
if not norm then norm = M.FORMAT_ALIASES[format:lower()] end
|
||||
return CONTENT_TYPES[norm]
|
||||
end
|
||||
|
||||
-- 目标格式的下载文件名后缀
|
||||
local FILENAME_EXT = {
|
||||
clashmeta = "yaml",
|
||||
stash = "yaml",
|
||||
surge = "conf",
|
||||
surfboard = "conf",
|
||||
surgemac = "conf",
|
||||
loon = "conf",
|
||||
egern = "conf",
|
||||
qx = "conf",
|
||||
shadowrocket = "txt",
|
||||
singbox = "json",
|
||||
v2ray = "json",
|
||||
v2rayuri = "txt",
|
||||
plain = "json",
|
||||
}
|
||||
|
||||
function M.extension_for(format)
|
||||
format = (format or "clash") or "clash"
|
||||
if type(format) ~= "string" then format = "clash" end
|
||||
local norm = M.FORMAT_ALIASES[format:lower():gsub("[-_%s]", "")]
|
||||
if not norm then norm = M.FORMAT_ALIASES[format:lower()] end
|
||||
return FILENAME_EXT[norm] or "txt"
|
||||
end
|
||||
|
||||
-- 统一分发:nodes → 目标格式字符串
|
||||
function M.generate(nodes, format, options)
|
||||
format = (format or "clash") or "clash"
|
||||
if type(format) ~= "string" then format = "clash" end
|
||||
local norm = M.FORMAT_ALIASES[format:lower():gsub("[-_%s]", "")]
|
||||
if not norm then norm = M.FORMAT_ALIASES[format:lower()] end
|
||||
if not norm then return nil, "unsupported format: " .. tostring(format) end
|
||||
|
||||
if norm == "clashmeta" then return clash_meta.generate(nodes, options) end
|
||||
if norm == "stash" then return output_formats.to_stash(nodes, options) end
|
||||
if norm == "surge" then return output_formats.to_surge(nodes, options) end
|
||||
if norm == "surfboard" then return output_formats.to_surfboard(nodes, options) end
|
||||
if norm == "surgemac" then return output_formats.to_surgemac(nodes, options) end
|
||||
if norm == "loon" then return output_formats.to_loon(nodes, options) end
|
||||
if norm == "egern" then return output_formats.to_egern(nodes, options) end
|
||||
if norm == "shadowrocket" then return output_uri.to_shadowrocket(nodes) end
|
||||
if norm == "qx" then return output_formats.to_qx(nodes, options) end
|
||||
if norm == "singbox" then return output_singbox.generate(nodes) end
|
||||
if norm == "v2ray" then return output_v2ray.generate(nodes) end
|
||||
if norm == "v2rayuri" then return output_uri.to_v2ray_uri(nodes) end
|
||||
if norm == "plain" then return output_formats.to_plain(nodes) end
|
||||
|
||||
return nil, "unsupported format: " .. tostring(format)
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -0,0 +1,276 @@
|
||||
-- output_clash_meta.lua — Clash.Meta / Mihomo 格式输出(纯 Lua)
|
||||
-- luci-app-substore
|
||||
|
||||
local M = {}
|
||||
|
||||
local function esc_yaml(s)
|
||||
s = tostring(s or "")
|
||||
s = s:gsub("\\", "\\\\"):gsub("\"", "\\\""):gsub("\n", "\\n")
|
||||
if s:find("[ :#{}[\],&*?|>'\"%@`]", 1, true) or s:match("^[-?]*:") then
|
||||
return '"' .. s .. '"'
|
||||
end
|
||||
return s
|
||||
end
|
||||
|
||||
local function indent(level)
|
||||
return string.rep(" ", level)
|
||||
end
|
||||
|
||||
local function yaml_list(items, level)
|
||||
local out = {}
|
||||
for _, v in ipairs(items) do
|
||||
out[#out + 1] = indent(level) .. "- " .. esc_yaml(v)
|
||||
end
|
||||
return table.concat(out, "\n")
|
||||
end
|
||||
|
||||
local PROTOCOL_TYPE_MAP = {
|
||||
vmess = "vmess",
|
||||
vless = "vless",
|
||||
trojan = "trojan",
|
||||
shadowsocks = "ss",
|
||||
ss = "ss",
|
||||
hysteria2 = "hysteria2",
|
||||
tuic = "tuic",
|
||||
wireguard = "wireguard",
|
||||
socks = "socks5",
|
||||
socks5 = "socks5",
|
||||
ssr = "ssr",
|
||||
}
|
||||
|
||||
local function get_clash_type(proto)
|
||||
return PROTOCOL_TYPE_MAP[proto] or proto
|
||||
end
|
||||
|
||||
local function format_node(node)
|
||||
local lines = {}
|
||||
local ctype = get_clash_type(node.proto)
|
||||
lines[#lines + 1] = " - name: " .. esc_yaml(node.name or "")
|
||||
lines[#lines + 1] = " type: " .. esc_yaml(ctype)
|
||||
lines[#lines + 1] = " server: " .. esc_yaml(node.server or "")
|
||||
lines[#lines + 1] = " port: " .. tostring(node.port or 0)
|
||||
|
||||
-- 协议通用字段
|
||||
if node.uuid then
|
||||
lines[#lines + 1] = " uuid: " .. esc_yaml(node.uuid)
|
||||
end
|
||||
if node.password then
|
||||
lines[#lines + 1] = " password: " .. esc_yaml(node.password)
|
||||
end
|
||||
if node.method then
|
||||
if ctype == "ss" then
|
||||
lines[#lines + 1] = " cipher: " .. esc_yaml(node.method)
|
||||
end
|
||||
end
|
||||
if node.cipher then
|
||||
if ctype == "vmess" or ctype == "vless" then
|
||||
lines[#lines + 1] = " cipher: " .. esc_yaml(node.cipher)
|
||||
end
|
||||
end
|
||||
if node.net or node.network then
|
||||
local net = node.net or node.network
|
||||
lines[#lines + 1] = " network: " .. esc_yaml(net)
|
||||
-- ws 特殊处理
|
||||
if net == "ws" then
|
||||
if node.path then
|
||||
lines[#lines + 1] = " ws-opts:"
|
||||
lines[#lines + 1] = " path: " .. esc_yaml(node.path)
|
||||
end
|
||||
if node.host then
|
||||
lines[#lines + 1] = " headers:"
|
||||
lines[#lines + 1] = " Host: " .. esc_yaml(node.host)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- TLS 相关
|
||||
local tls = false
|
||||
if node.security and node.security ~= "none" then
|
||||
tls = true
|
||||
elseif node.tls then
|
||||
tls = true
|
||||
end
|
||||
if tls then
|
||||
lines[#lines + 1] = " tls: true"
|
||||
end
|
||||
|
||||
-- servername / sni
|
||||
if node.sni then
|
||||
lines[#lines + 1] = " servername: " .. esc_yaml(node.sni)
|
||||
end
|
||||
if node.servername then
|
||||
lines[#lines + 1] = " servername: " .. esc_yaml(node.servername)
|
||||
end
|
||||
|
||||
-- 特殊字段
|
||||
if node["skip-cert-verify"] ~= nil then
|
||||
lines[#lines + 1] = " skip-cert-verify: " .. tostring(node["skip-cert-verify"])
|
||||
end
|
||||
if node.udp ~= nil then
|
||||
lines[#lines + 1] = " udp: " .. tostring(node.udp)
|
||||
end
|
||||
if node.alpn then
|
||||
lines[#lines + 1] = " alpn:"
|
||||
local alpn_list = {}
|
||||
if type(node.alpn) == "string" then
|
||||
for part in node.alpn:gmatch("[^,]+") do
|
||||
alpn_list[#alpn_list + 1] = part:match("^%s*(.-)%s*$")
|
||||
end
|
||||
elseif type(node.alpn) == "table" then
|
||||
alpn_list = node.alpn
|
||||
end
|
||||
for _, v in ipairs(alpn_list) do
|
||||
lines[#lines + 1] = " - " .. esc_yaml(v)
|
||||
end
|
||||
end
|
||||
if node.fp then
|
||||
lines[#lines + 1] = " fp: " .. esc_yaml(node.fp)
|
||||
end
|
||||
|
||||
-- 协议特定字段
|
||||
if ctype == "vmess" then
|
||||
if node.alterId ~= nil then
|
||||
lines[#lines + 1] = " alterId: " .. tostring(node.alterId)
|
||||
end
|
||||
if not node.cipher then
|
||||
lines[#lines + 1] = " cipher: auto"
|
||||
end
|
||||
end
|
||||
|
||||
if ctype == "hysteria2" then
|
||||
if node.sni then
|
||||
lines[#lines + 1] = " sni: " .. esc_yaml(node.sni)
|
||||
end
|
||||
-- 混淆(salamander)
|
||||
if node.obfs and node.obfs ~= "" and node.obfs ~= "plain" then
|
||||
lines[#lines + 1] = " obfs: " .. esc_yaml(node.obfs)
|
||||
local opw = node["obfs-password"] or node.obfs_password
|
||||
if opw and opw ~= "" then
|
||||
lines[#lines + 1] = " obfs-password: " .. esc_yaml(opw)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if ctype == "tuic" then
|
||||
if node["udp-relay-mode"] then
|
||||
lines[#lines + 1] = " udp-relay-mode: " .. esc_yaml(node["udp-relay-mode"])
|
||||
end
|
||||
end
|
||||
|
||||
if ctype == "wireguard" then
|
||||
if node["private-key"] then
|
||||
lines[#lines + 1] = " private-key: " .. esc_yaml(node["private-key"])
|
||||
end
|
||||
if node["peer-public-key"] then
|
||||
lines[#lines + 1] = " peer-public-key: " .. esc_yaml(node["peer-public-key"])
|
||||
end
|
||||
if node["preshared-key"] then
|
||||
lines[#lines + 1] = " preshared-key: " .. esc_yaml(node["preshared-key"])
|
||||
end
|
||||
if node.uuid then
|
||||
lines[#lines + 1] = " uuid: " .. esc_yaml(node.uuid)
|
||||
end
|
||||
end
|
||||
|
||||
if ctype == "socks5" then
|
||||
if node.username then
|
||||
lines[#lines + 1] = " username: " .. esc_yaml(node.username)
|
||||
end
|
||||
if node.password then
|
||||
lines[#lines + 1] = " password: " .. esc_yaml(node.password)
|
||||
end
|
||||
end
|
||||
|
||||
if ctype == "ssr" then
|
||||
lines[#lines + 1] = " cipher: " .. esc_yaml(node.method or node.cipher or "aes-128-cfb")
|
||||
lines[#lines + 1] = " protocol: " .. esc_yaml(node.protocol or "origin")
|
||||
lines[#lines + 1] = " obfs: " .. esc_yaml(node.obfs or "plain")
|
||||
local op = node.obfs_param or node["obfs-param"]
|
||||
local pp = node.protocol_param or node["protocol-param"]
|
||||
if op and op ~= "" then lines[#lines + 1] = " obfs-param: " .. esc_yaml(op) end
|
||||
if pp and pp ~= "" then lines[#lines + 1] = " protocol-param: " .. esc_yaml(pp) end
|
||||
end
|
||||
|
||||
return table.concat(lines, "\n")
|
||||
end
|
||||
|
||||
local function generate_proxies(nodes)
|
||||
if not nodes or #nodes == 0 then
|
||||
return "proxies: []"
|
||||
end
|
||||
local out = {}
|
||||
out[#out + 1] = "proxies:"
|
||||
for _, node in ipairs(nodes) do
|
||||
out[#out + 1] = format_node(node)
|
||||
end
|
||||
return table.concat(out, "\n")
|
||||
end
|
||||
|
||||
local function generate_groups(nodes, options)
|
||||
local out = {}
|
||||
out[#out + 1] = "proxy-groups:"
|
||||
|
||||
local names = {}
|
||||
for _, n in ipairs(nodes) do
|
||||
names[#names + 1] = n.name or ""
|
||||
end
|
||||
|
||||
local group_name = (options and options.name) or "Proxy"
|
||||
|
||||
-- SELECT
|
||||
out[#out + 1] = " - name: " .. esc_yaml(group_name)
|
||||
out[#out + 1] = " type: select"
|
||||
out[#out + 1] = " proxies:"
|
||||
if #names > 0 then
|
||||
for _, n in ipairs(names) do
|
||||
out[#out + 1] = " - " .. esc_yaml(n)
|
||||
end
|
||||
else
|
||||
out[#out + 1] = " - REJECT"
|
||||
end
|
||||
|
||||
-- URL-TEST
|
||||
out[#out + 1] = " - name: URL-Test"
|
||||
out[#out + 1] = " type: url-test"
|
||||
out[#out + 1] = " url: http://www.gstatic.com/generate_204"
|
||||
out[#out + 1] = " interval: 300"
|
||||
out[#out + 1] = " tolerance: 50"
|
||||
out[#out + 1] = " proxies:"
|
||||
if #names > 0 then
|
||||
for _, n in ipairs(names) do
|
||||
out[#out + 1] = " - " .. esc_yaml(n)
|
||||
end
|
||||
else
|
||||
out[#out + 1] = " - REJECT"
|
||||
end
|
||||
|
||||
-- LOAD-BALANCE
|
||||
out[#out + 1] = " - name: Load-Balance"
|
||||
out[#out + 1] = " type: load-balance"
|
||||
out[#out + 1] = " strategy: round-robin"
|
||||
out[#out + 1] = " proxies:"
|
||||
if #names > 0 then
|
||||
for _, n in ipairs(names) do
|
||||
out[#out + 1] = " - " .. esc_yaml(n)
|
||||
end
|
||||
else
|
||||
out[#out + 1] = " - REJECT"
|
||||
end
|
||||
|
||||
return table.concat(out, "\n")
|
||||
end
|
||||
|
||||
function M.generate(nodes, options)
|
||||
options = options or {}
|
||||
local parts = {}
|
||||
|
||||
-- 生成 proxies
|
||||
parts[#parts + 1] = generate_proxies(nodes or {})
|
||||
|
||||
-- 生成 proxy-groups
|
||||
parts[#parts + 1] = generate_groups(nodes or {}, options)
|
||||
|
||||
return table.concat(parts, "\n\n")
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -0,0 +1,217 @@
|
||||
-- output_formats.lua — Surge 系 / Loon / QX / Egern / Stash / Plain JSON 输出(纯 Lua)
|
||||
-- luci-app-substore
|
||||
|
||||
local util = require("substore.util")
|
||||
local clash_meta = require("substore.output_clash_meta")
|
||||
|
||||
local M = {}
|
||||
|
||||
-- 规范化协议名
|
||||
local function props(n)
|
||||
local proto = (n.proto or ""):lower()
|
||||
if proto == "ss" then proto = "shadowsocks" end
|
||||
return proto
|
||||
end
|
||||
|
||||
-- 生成 Surge 风格代理行(Surge / Surfboard / SurgeMac / Loon / Egern 通用)
|
||||
function M.surge_line(n)
|
||||
local proto = props(n)
|
||||
local head = proto .. ", " .. (n.server or "") .. ", " .. tostring(n.port or 0)
|
||||
local e = {}
|
||||
local tls = (n.security and n.security ~= "none") or (n.tls and n.tls ~= false and n.tls ~= "none")
|
||||
|
||||
if proto == "shadowsocks" then
|
||||
e[#e + 1] = "encrypt-method=" .. (n.method or n.cipher or "aes-256-gcm")
|
||||
e[#e + 1] = "password=" .. (n.password or "")
|
||||
elseif proto == "vmess" then
|
||||
e[#e + 1] = "username=" .. (n.uuid or "")
|
||||
if tls then e[#e + 1] = "tls=true" end
|
||||
if n.sni then e[#e + 1] = "sni=" .. n.sni end
|
||||
if n.net == "ws" then
|
||||
e[#e + 1] = "ws=true"
|
||||
if n.path then e[#e + 1] = "ws-path=" .. n.path end
|
||||
if n.host then e[#e + 1] = "ws-headers=Host:" .. n.host end
|
||||
end
|
||||
elseif proto == "vless" then
|
||||
e[#e + 1] = "username=" .. (n.uuid or "")
|
||||
if tls then e[#e + 1] = "tls=true" end
|
||||
if n.sni then e[#e + 1] = "sni=" .. n.sni end
|
||||
if n.flow then e[#e + 1] = "flow=" .. n.flow end
|
||||
elseif proto == "trojan" then
|
||||
e[#e + 1] = "password=" .. (n.password or "")
|
||||
e[#e + 1] = "tls=true"
|
||||
if n.sni then e[#e + 1] = "sni=" .. n.sni end
|
||||
elseif proto == "ssr" then
|
||||
e[#e + 1] = "encrypt-method=" .. (n.method or n.cipher or "aes-128-cfb")
|
||||
e[#e + 1] = "password=" .. (n.password or "")
|
||||
e[#e + 1] = "protocol=" .. (n.protocol or "origin")
|
||||
e[#e + 1] = "obfs=" .. (n.obfs or "plain")
|
||||
local op = n.obfs_param or n["obfs-param"]
|
||||
local pp = n.protocol_param or n["protocol-param"]
|
||||
if op and op ~= "" then e[#e + 1] = "obfs-param=" .. op end
|
||||
if pp and pp ~= "" then e[#e + 1] = "protocol-param=" .. pp end
|
||||
elseif proto == "hysteria2" or proto == "hysteria" then
|
||||
e[#e + 1] = "password=" .. (n.password or "")
|
||||
if n.sni then e[#e + 1] = "sni=" .. n.sni end
|
||||
elseif proto == "tuic" then
|
||||
e[#e + 1] = "username=" .. (n.uuid or "")
|
||||
if n.password then e[#e + 1] = "password=" .. n.password end
|
||||
if n.sni then e[#e + 1] = "sni=" .. n.sni end
|
||||
if n.alpn then e[#e + 1] = "alpn=" .. n.alpn end
|
||||
elseif proto == "socks5" or proto == "socks" then
|
||||
if n.username then
|
||||
e[#e + 1] = "username=" .. n.username
|
||||
if n.password then e[#e + 1] = "password=" .. n.password end
|
||||
end
|
||||
end
|
||||
|
||||
if n["skip-cert-verify"] then e[#e + 1] = "skip-cert-verify=1" end
|
||||
if n.udp then e[#e + 1] = "udp-relay=true" end
|
||||
|
||||
local line = (n.name or "") .. " = " .. head
|
||||
if #e > 0 then line = line .. ", " .. table.concat(e, ", ") end
|
||||
return line
|
||||
end
|
||||
|
||||
-- 收集节点名列表
|
||||
local function names_of(nodes)
|
||||
local out = {}
|
||||
for _, n in ipairs(nodes or {}) do
|
||||
if n.name then out[#out + 1] = n.name end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- Surge 家族配置(Surge / Surfboard / SurgeMac / Loon / Egern 通用)
|
||||
-- supports_ssr:Loon / Egern 支持 SSR;Surge / Surfboard / SurgeMac 不支持,跳过 ssr 节点
|
||||
local function surge_config(nodes, group_name, supports_ssr)
|
||||
local list = nodes or {}
|
||||
-- 过滤 Surge 家族无法用单行 [Proxy] 表达的协议:
|
||||
-- ssr:仅 Loon / Egern 支持(supports_ssr),Surge/Surfboard/SurgeMac 丢
|
||||
-- wireguard:Surge 需专用多段 [WireGuard] 配置,单行无法表达,统一丢弃(不输出损坏行)
|
||||
local kept = {}
|
||||
for _, n in ipairs(list) do
|
||||
local p = (n.proto or ""):lower()
|
||||
if p == "wireguard" then
|
||||
-- drop
|
||||
elseif not supports_ssr and p == "ssr" then
|
||||
-- drop
|
||||
else
|
||||
kept[#kept + 1] = n
|
||||
end
|
||||
end
|
||||
list = kept
|
||||
local out = {}
|
||||
out[#out + 1] = "[Proxy]"
|
||||
for _, n in ipairs(list) do
|
||||
out[#out + 1] = M.surge_line(n)
|
||||
end
|
||||
out[#out + 1] = ""
|
||||
out[#out + 1] = "[Proxy Group]"
|
||||
local names = names_of(list)
|
||||
local select = group_name .. " = select"
|
||||
for _, nm in ipairs(names) do select = select .. ", " .. nm end
|
||||
select = select .. ", DIRECT"
|
||||
out[#out + 1] = select
|
||||
return table.concat(out, "\n")
|
||||
end
|
||||
|
||||
function M.to_surge(nodes, options)
|
||||
options = options or {}
|
||||
return surge_config(nodes, options.name or "PROXY", false)
|
||||
end
|
||||
|
||||
function M.to_surfboard(nodes, options)
|
||||
options = options or {}
|
||||
return surge_config(nodes, options.name or "PROXY", false)
|
||||
end
|
||||
|
||||
function M.to_surgemac(nodes, options)
|
||||
options = options or {}
|
||||
return surge_config(nodes, options.name or "PROXY", false)
|
||||
end
|
||||
|
||||
-- Loon / Egern:Surge 兼容语法(支持 SSR)
|
||||
function M.to_loon(nodes, options)
|
||||
options = options or {}
|
||||
return surge_config(nodes, options.name or "PROXY", true)
|
||||
end
|
||||
|
||||
function M.to_egern(nodes, options)
|
||||
options = options or {}
|
||||
return surge_config(nodes, options.name or "PROXY", true)
|
||||
end
|
||||
|
||||
-- Stash:Clash 兼容 YAML
|
||||
function M.to_stash(nodes, options)
|
||||
return clash_meta.generate(nodes, options)
|
||||
end
|
||||
|
||||
-- Quantumult X
|
||||
function M.to_qx(nodes, options)
|
||||
options = options or {}
|
||||
local out = {}
|
||||
out[#out + 1] = "[server_local]"
|
||||
for _, n in ipairs(nodes or {}) do
|
||||
local proto = props(n)
|
||||
local host = (n.server or "") .. ":" .. tostring(n.port or 0)
|
||||
local tag = n.name or host
|
||||
if proto == "shadowsocks" then
|
||||
out[#out + 1] = string.format(
|
||||
"shadowsocks=%s, method=%s, password=%s, tag=%s",
|
||||
host, n.method or n.cipher or "aes-256-gcm", n.password or "", tag)
|
||||
elseif proto == "vmess" then
|
||||
out[#out + 1] = string.format(
|
||||
"vmess=%s, method=none, password=%s, tag=%s",
|
||||
host, n.uuid or "", tag)
|
||||
if n.net == "ws" then
|
||||
local esc = out[#out]
|
||||
esc = esc .. ", obfs=ws"
|
||||
if n.path then esc = esc .. ", obfs-uri=" .. n.path end
|
||||
if n.host then esc = esc .. ", obfs-host=" .. n.host end
|
||||
out[#out] = esc
|
||||
end
|
||||
if n.sni then out[#out] = out[#out] .. ", tls-host=" .. n.sni end
|
||||
if n.security and n.security ~= "none" then out[#out] = out[#out] .. ", tls-verification=true" end
|
||||
elseif proto == "vless" then
|
||||
out[#out + 1] = string.format(
|
||||
"vless=%s, method=none, password=%s, tag=%s",
|
||||
host, n.uuid or "", tag)
|
||||
elseif proto == "trojan" then
|
||||
out[#out + 1] = string.format(
|
||||
"trojan=%s, password=%s, over-tls=true, tag=%s",
|
||||
host, n.password or "", tag)
|
||||
if n.sni then out[#out] = out[#out] .. ", tls-host=" .. n.sni end
|
||||
end
|
||||
end
|
||||
out[#out + 1] = ""
|
||||
out[#out + 1] = "[policy]"
|
||||
local names = names_of(nodes)
|
||||
local policy = "static=" .. (options.name or "PROXY") .. ", DIRECT"
|
||||
for _, nm in ipairs(names) do policy = policy .. ", " .. nm end
|
||||
out[#out + 1] = policy
|
||||
out[#out + 1] = ""
|
||||
out[#out + 1] = "[server_remote]"
|
||||
return table.concat(out, "\n")
|
||||
end
|
||||
|
||||
-- Plain JSON:节点统一模型 JSON 数组
|
||||
function M.to_plain(nodes)
|
||||
return util.json_encode(nodes or {})
|
||||
end
|
||||
|
||||
-- 统一分发
|
||||
function M.generate(nodes, format, options)
|
||||
format = (format or ""):lower()
|
||||
if format == "surge" then return M.to_surge(nodes, options) end
|
||||
if format == "surfboard" then return M.to_surfboard(nodes, options) end
|
||||
if format == "surgemac" then return M.to_surgemac(nodes, options) end
|
||||
if format == "loon" then return M.to_loon(nodes, options) end
|
||||
if format == "egern" then return M.to_egern(nodes, options) end
|
||||
if format == "qx" then return M.to_qx(nodes, options) end
|
||||
if format == "stash" then return M.to_stash(nodes, options) end
|
||||
if format == "plain" then return M.to_plain(nodes) end
|
||||
return nil, "unsupported format: " .. tostring(format)
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -0,0 +1,147 @@
|
||||
-- output_singbox.lua — sing-box JSON 配置输出(纯 Lua)
|
||||
-- luci-app-substore
|
||||
|
||||
local util = require("substore.util")
|
||||
|
||||
local M = {}
|
||||
|
||||
local function bool(v)
|
||||
if v == nil then return nil end
|
||||
if v == false or v == "false" or v == 0 or v == "0" then return false end
|
||||
return true
|
||||
end
|
||||
|
||||
-- sing-box type 映射
|
||||
local TYPE_MAP = {
|
||||
vmess = "vmess",
|
||||
vless = "vless",
|
||||
trojan = "trojan",
|
||||
shadowsocks = "shadowsocks",
|
||||
ss = "shadowsocks",
|
||||
hysteria2 = "hysteria2",
|
||||
hysteria = "hysteria",
|
||||
tuic = "tuic",
|
||||
wireguard = "wireguard",
|
||||
socks = "socks",
|
||||
socks5 = "socks",
|
||||
http = "http",
|
||||
}
|
||||
|
||||
-- 构建 TLS 字段
|
||||
local function build_tls(n)
|
||||
local tls
|
||||
if n.security and n.security ~= "none" then
|
||||
tls = {}
|
||||
if n.sni or n.servername then tls.server_name = n.sni or n.servername end
|
||||
if n.alpn then
|
||||
if type(n.alpn) == "string" then
|
||||
local list = {}
|
||||
for p in n.alpn:gmatch("[^,]+") do list[#list + 1] = p:match("^%s*(.-)%s*$") end
|
||||
tls.alpn = list
|
||||
else
|
||||
tls.alpn = n.alpn
|
||||
end
|
||||
end
|
||||
if n["skip-cert-verify"] ~= nil then
|
||||
tls.insecure = bool(n["skip-cert-verify"])
|
||||
elseif n.skip_cert_verify ~= nil then
|
||||
tls.insecure = bool(n.skip_cert_verify)
|
||||
end
|
||||
return tls
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- 构建传输(transport)字段
|
||||
local function build_transport(n)
|
||||
local net = n.net or n.network
|
||||
if not net or net == "tcp" then return nil end
|
||||
if net == "ws" then
|
||||
local t = { type = "ws" }
|
||||
if n.path then t.path = n.path end
|
||||
if n.host then
|
||||
t.headers = { Host = n.host }
|
||||
end
|
||||
return t
|
||||
end
|
||||
if net == "grpc" then
|
||||
local t = { type = "grpc" }
|
||||
if n.path then t.service_name = n.path end
|
||||
return t
|
||||
end
|
||||
if net == "http" or net == "h2" then
|
||||
local t = { type = "http" }
|
||||
if n.host then t.host = { n.host } end
|
||||
if n.path then t.path = n.path end
|
||||
return t
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- 单节点 → sing-box outbound 表
|
||||
function M.to_outbound(n)
|
||||
local stype = TYPE_MAP[n.proto] or n.proto
|
||||
if stype == "ssr" then return nil end -- sing-box 不支持 SSR,跳过
|
||||
local o = {
|
||||
type = stype,
|
||||
tag = n.name or ((n.server or "") .. ":" .. tostring(n.port or "")),
|
||||
server = n.server or "",
|
||||
server_port = tonumber(n.port) or 0,
|
||||
}
|
||||
|
||||
if stype == "vmess" then
|
||||
o.uuid = n.uuid or ""
|
||||
if n.alterId ~= nil then o.alter_id = tonumber(n.alterId) end
|
||||
if n.security then o.security = n.security end
|
||||
if n.flow then o.flow = n.flow end
|
||||
elseif stype == "vless" then
|
||||
o.uuid = n.uuid or ""
|
||||
if n.flow then o.flow = n.flow end
|
||||
elseif stype == "trojan" then
|
||||
o.password = n.password or ""
|
||||
elseif stype == "shadowsocks" then
|
||||
o.method = n.method or n.cipher or "aes-256-gcm"
|
||||
o.password = n.password or ""
|
||||
elseif stype == "hysteria2" or stype == "hysteria" then
|
||||
o.password = n.password or ""
|
||||
-- 混淆(salamander)
|
||||
if n.obfs and n.obfs ~= "" and n.obfs ~= "plain" then
|
||||
o.obfs = { type = n.obfs, password = n["obfs-password"] or n.obfs_password or "" }
|
||||
end
|
||||
elseif stype == "tuic" then
|
||||
o.uuid = n.uuid or ""
|
||||
o.password = n.password or ""
|
||||
if n.congestion_control then o.congestion_control = n.congestion_control end
|
||||
elseif stype == "wireguard" then
|
||||
local pk = n["private-key"] or n.private_key
|
||||
local ppk = n["peer-public-key"] or n.peer_public_key
|
||||
local psk = n["preshared-key"] or n.preshared_key
|
||||
if pk then o.private_key = pk end
|
||||
if ppk then o.peer_public_key = ppk end
|
||||
if psk then o.preshared_key = psk end
|
||||
if n.mtu then o.mtu = tonumber(n.mtu) end
|
||||
elseif stype == "socks" then
|
||||
if n.username then o.username = n.username end
|
||||
if n.password then o.password = n.password end
|
||||
end
|
||||
|
||||
local tls = build_tls(n)
|
||||
if tls then o.tls = tls end
|
||||
local transport = build_transport(n)
|
||||
if transport then o.transport = transport end
|
||||
|
||||
return o
|
||||
end
|
||||
|
||||
function M.generate(nodes)
|
||||
local outbounds = {}
|
||||
for _, n in ipairs(nodes or {}) do
|
||||
if type(n) == "table" then
|
||||
local o = M.to_outbound(n)
|
||||
if o then outbounds[#outbounds + 1] = o end
|
||||
end
|
||||
end
|
||||
return util.json_encode({ outbounds = outbounds })
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -0,0 +1,177 @@
|
||||
-- output_uri.lua — 分享链接(URI)输出:Shadowrocket / V2Ray URI(纯 Lua)
|
||||
-- luci-app-substore
|
||||
-- 生成 vmess:// / vless:// / trojan:// / ss:// / hysteria2:// / tuic:// / ssr:// 分享链接
|
||||
|
||||
local util = require("substore.util")
|
||||
|
||||
local M = {}
|
||||
|
||||
-- URL 编码:保留字母数字 - . _ ~,其余转 %XX
|
||||
local function url_encode(s)
|
||||
s = tostring(s or "")
|
||||
return (s:gsub("([^%w%-%.%_%~])", function(c)
|
||||
return string.format("%%%02X", c:byte())
|
||||
end))
|
||||
end
|
||||
|
||||
-- 生成单节点分享链接;无法生成时返回 nil
|
||||
function M.to_share_uri(n)
|
||||
if type(n) ~= "table" or not n.server or not n.port then return nil end
|
||||
local server = n.server
|
||||
local port = tonumber(n.port) or 0
|
||||
local name = url_encode(n.name or (server .. ":" .. tostring(port)))
|
||||
local proto = (n.proto or ""):lower()
|
||||
|
||||
if proto == "ss" then proto = "shadowsocks" end
|
||||
|
||||
if proto == "shadowsocks" then
|
||||
local method = n.method or n.cipher or "aes-256-gcm"
|
||||
local password = n.password or ""
|
||||
local userinfo = util.base64_encode(method .. ":" .. password)
|
||||
return "ss://" .. userinfo .. "@" .. server .. ":" .. tostring(port) .. "#" .. name
|
||||
end
|
||||
|
||||
if proto == "vmess" then
|
||||
local json = {
|
||||
v = "2",
|
||||
ps = n.name or (server .. ":" .. tostring(port)),
|
||||
add = server,
|
||||
port = tostring(port),
|
||||
id = n.uuid or "",
|
||||
aid = tostring(n.alterId or n.aid or 0),
|
||||
scy = n.security or "auto",
|
||||
net = n.net or n.network or "tcp",
|
||||
type = n.type or n.headerType or "none",
|
||||
host = n.host or "",
|
||||
path = n.path or "",
|
||||
tls = (n.tls and n.tls ~= "none" and n.tls ~= false) and "tls" or "",
|
||||
}
|
||||
return "vmess://" .. util.base64_encode(util.json_encode(json))
|
||||
end
|
||||
|
||||
if proto == "vless" then
|
||||
local q = {}
|
||||
q[#q + 1] = "encryption=none"
|
||||
q[#q + 1] = "type=" .. url_encode(n.net or n.network or "tcp")
|
||||
q[#q + 1] = "security=" .. url_encode(n.security or "none")
|
||||
if n.sni then q[#q + 1] = "sni=" .. url_encode(n.sni) end
|
||||
if n.fp then q[#q + 1] = "fp=" .. url_encode(n.fp) end
|
||||
if n.alpn then
|
||||
local alpn = type(n.alpn) == "table" and table.concat(n.alpn, ",") or n.alpn
|
||||
q[#q + 1] = "alpn=" .. url_encode(alpn)
|
||||
end
|
||||
if n.path then q[#q + 1] = "path=" .. url_encode(n.path) end
|
||||
if n.host then q[#q + 1] = "host=" .. url_encode(n.host) end
|
||||
if n.flow then q[#q + 1] = "flow=" .. url_encode(n.flow) end
|
||||
return "vless://" .. (n.uuid or "") .. "@" .. server .. ":" .. tostring(port)
|
||||
.. "?" .. table.concat(q, "&") .. "#" .. name
|
||||
end
|
||||
|
||||
if proto == "trojan" then
|
||||
local q = {}
|
||||
q[#q + 1] = "security=" .. url_encode(n.security or "tls")
|
||||
if n.sni then q[#q + 1] = "sni=" .. url_encode(n.sni) end
|
||||
if n.alpn then q[#q + 1] = "alpn=" .. url_encode(n.alpn) end
|
||||
if n.fp then q[#q + 1] = "fp=" .. url_encode(n.fp) end
|
||||
return "trojan://" .. url_encode(n.password or "") .. "@" .. server .. ":" .. tostring(port)
|
||||
.. "?" .. table.concat(q, "&") .. "#" .. name
|
||||
end
|
||||
|
||||
if proto == "hysteria2" or proto == "hysteria" then
|
||||
local q = {}
|
||||
if n.sni then q[#q + 1] = "sni=" .. url_encode(n.sni) end
|
||||
if n.insecure ~= nil then q[#q + 1] = "insecure=" .. tostring(n.insecure) end
|
||||
-- 混淆(salamander):hy2 URI 标准参数 obfs / obfs-password
|
||||
if n.obfs and n.obfs ~= "" and n.obfs ~= "plain" then
|
||||
q[#q + 1] = "obfs=" .. url_encode(n.obfs)
|
||||
local opw = n["obfs-password"] or n.obfs_password
|
||||
if opw and opw ~= "" then q[#q + 1] = "obfs-password=" .. url_encode(opw) end
|
||||
end
|
||||
local suffix = #q > 0 and ("?" .. table.concat(q, "&")) or ""
|
||||
return (proto == "hysteria2" and "hysteria2://" or "hysteria://")
|
||||
.. url_encode(n.password or "") .. "@" .. server .. ":" .. tostring(port)
|
||||
.. suffix .. "#" .. name
|
||||
end
|
||||
|
||||
if proto == "tuic" then
|
||||
local userinfo = (n.uuid or "") .. ":" .. url_encode(n.password or "")
|
||||
local q = {}
|
||||
if n.congestion_control then q[#q + 1] = "congestion_control=" .. url_encode(n.congestion_control) end
|
||||
if n.alpn then q[#q + 1] = "alpn=" .. url_encode(n.alpn) end
|
||||
if n.sni then q[#q + 1] = "sni=" .. url_encode(n.sni) end
|
||||
local suffix = #q > 0 and ("?" .. table.concat(q, "&")) or ""
|
||||
return "tuic://" .. userinfo .. "@" .. server .. ":" .. tostring(port)
|
||||
.. suffix .. "#" .. name
|
||||
end
|
||||
|
||||
if proto == "socks5" or proto == "socks" then
|
||||
local userinfo = ""
|
||||
if n.username then userinfo = url_encode(n.username)
|
||||
if n.password then userinfo = userinfo .. ":" .. url_encode(n.password) end
|
||||
userinfo = userinfo .. "@"
|
||||
end
|
||||
return "socks5://" .. userinfo .. server .. ":" .. tostring(port) .. "#" .. name
|
||||
end
|
||||
|
||||
if proto == "ssr" then
|
||||
return M.to_ssr_uri(n)
|
||||
end
|
||||
|
||||
if proto == "wireguard" then
|
||||
-- wireguard://base64(json)#name —— 本项目自定义 scheme(wireguard 无统一 URI 标准)
|
||||
local json = {
|
||||
server = server,
|
||||
port = tostring(port),
|
||||
["private-key"] = n["private-key"] or n.private_key,
|
||||
["peer-public-key"] = n["peer-public-key"] or n.peer_public_key,
|
||||
["preshared-key"] = n["preshared-key"] or n.preshared_key,
|
||||
}
|
||||
if n.mtu then json.mtu = tostring(n.mtu) end
|
||||
if n.name then json.name = n.name end
|
||||
return "wireguard://" .. util.base64_encode(util.json_encode(json)) .. "#" .. name
|
||||
end
|
||||
|
||||
return nil
|
||||
end
|
||||
|
||||
-- 生成 ssr:// 分享链接(外层与密码为标准 base64,参数为 base64url)
|
||||
function M.to_ssr_uri(n)
|
||||
if type(n) ~= "table" or not n.server then return nil end
|
||||
local server = n.server
|
||||
local port = tostring(tonumber(n.port) or 0)
|
||||
local protocol = n.protocol or "origin"
|
||||
local method = n.method or n.cipher or "aes-128-cfb"
|
||||
local obfs = n.obfs or "plain"
|
||||
local password = n.password or ""
|
||||
local main = table.concat({ server, port, protocol, method, obfs, util.base64_encode(password) }, ":")
|
||||
local q = {}
|
||||
local op = n.obfs_param or n["obfs-param"]
|
||||
local pp = n.protocol_param or n["protocol-param"]
|
||||
if op and op ~= "" then q[#q + 1] = "obfsparam=" .. util.base64_url_encode(op) end
|
||||
if pp and pp ~= "" then q[#q + 1] = "protoparam=" .. util.base64_url_encode(pp) end
|
||||
q[#q + 1] = "remarks=" .. util.base64_url_encode(n.name or server)
|
||||
if n.group and n.group ~= "" then q[#q + 1] = "group=" .. util.base64_url_encode(n.group) end
|
||||
return "ssr://" .. util.base64_encode(main .. "/?" .. table.concat(q, "&"))
|
||||
end
|
||||
|
||||
-- 生成 URI 列表(每行一条,丢弃无法生成 URI 的节点)
|
||||
function M.to_uri_list(nodes)
|
||||
local out = {}
|
||||
for _, n in ipairs(nodes or {}) do
|
||||
local u = M.to_share_uri(n)
|
||||
if u then out[#out + 1] = u end
|
||||
end
|
||||
return table.concat(out, "\n")
|
||||
end
|
||||
|
||||
-- Shadowrocket 订阅:base64 编码的 URI 列表
|
||||
function M.to_shadowrocket(nodes)
|
||||
return util.base64_encode(M.to_uri_list(nodes))
|
||||
end
|
||||
|
||||
-- V2Ray URI 订阅:明文 URI 列表
|
||||
function M.to_v2ray_uri(nodes)
|
||||
return M.to_uri_list(nodes)
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -0,0 +1,108 @@
|
||||
-- output_v2ray.lua — V2Ray / Xray JSON 配置输出(纯 Lua)
|
||||
-- luci-app-substore
|
||||
|
||||
local util = require("substore.util")
|
||||
|
||||
local M = {}
|
||||
|
||||
-- 构建 streamSettings
|
||||
local function build_stream_settings(n)
|
||||
local ss = {}
|
||||
local net = n.net or n.network or "tcp"
|
||||
ss.network = net
|
||||
|
||||
-- 传输层
|
||||
if net == "ws" then
|
||||
local ws = {}
|
||||
if n.path then ws.path = n.path end
|
||||
if n.host then ws.headers = { Host = n.host } end
|
||||
ss.wsSettings = ws
|
||||
elseif net == "grpc" then
|
||||
local g = {}
|
||||
if n.path then g.serviceName = n.path end
|
||||
ss.grpcSettings = g
|
||||
elseif net == "http" or net == "h2" then
|
||||
local h = {}
|
||||
if n.host then h.host = { n.host } end
|
||||
if n.path then h.path = n.path end
|
||||
ss.httpSettings = h
|
||||
elseif net == "kcp" then
|
||||
ss.kcpSettings = { header = { type = n.headerType or "none" } }
|
||||
end
|
||||
|
||||
-- 安全层
|
||||
local security = n.security
|
||||
if security and security ~= "none" then
|
||||
ss.security = security
|
||||
local sni = n.sni or n.servername
|
||||
if sni then
|
||||
ss.tlsSettings = { serverName = sni, allowInsecure = false }
|
||||
end
|
||||
elseif n.tls and n.tls ~= "none" and n.tls ~= false then
|
||||
ss.security = "tls"
|
||||
local sni = n.sni or n.servername
|
||||
if sni then ss.tlsSettings = { serverName = sni } end
|
||||
end
|
||||
|
||||
return ss
|
||||
end
|
||||
|
||||
-- 单节点 → V2Ray outbound 表
|
||||
function M.to_outbound(n)
|
||||
local proto = n.proto or "vmess"
|
||||
if proto == "ssr" then return nil end -- V2Ray / Xray 不支持 SSR,跳过
|
||||
local o = {
|
||||
protocol = (proto == "ss" and "shadowsocks" or proto),
|
||||
tag = n.name or ((n.server or "") .. ":" .. tostring(n.port or "")),
|
||||
settings = {},
|
||||
streamSettings = build_stream_settings(n),
|
||||
}
|
||||
local server, port = n.server or "", tonumber(n.port) or 0
|
||||
|
||||
if proto == "vmess" then
|
||||
o.settings.vnext = { {
|
||||
address = server,
|
||||
port = port,
|
||||
users = { {
|
||||
id = n.uuid or "",
|
||||
alterId = tonumber(n.alterId or n.aid or 0),
|
||||
security = n.security or "auto",
|
||||
} },
|
||||
} }
|
||||
elseif proto == "vless" then
|
||||
local user = { id = n.uuid or "", encryption = "none" }
|
||||
if n.flow then user.flow = n.flow end
|
||||
o.settings.vnext = { { address = server, port = port, users = { user } } }
|
||||
elseif proto == "trojan" then
|
||||
o.settings.servers = { { address = server, port = port, password = n.password or "" } }
|
||||
elseif proto == "shadowsocks" or proto == "ss" then
|
||||
o.settings.servers = { {
|
||||
address = server,
|
||||
port = port,
|
||||
method = n.method or n.cipher or "aes-256-gcm",
|
||||
password = n.password or "",
|
||||
} }
|
||||
else
|
||||
-- socks / http 等
|
||||
o.settings.servers = { {
|
||||
address = server,
|
||||
port = port,
|
||||
users = n.username and { { user = n.username, pass = n.password or "" } } or nil,
|
||||
} }
|
||||
end
|
||||
|
||||
return o
|
||||
end
|
||||
|
||||
function M.generate(nodes)
|
||||
local outbounds = {}
|
||||
for _, n in ipairs(nodes or {}) do
|
||||
if type(n) == "table" then
|
||||
local o = M.to_outbound(n)
|
||||
if o then outbounds[#outbounds + 1] = o end
|
||||
end
|
||||
end
|
||||
return util.json_encode({ outbounds = outbounds })
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -0,0 +1,720 @@
|
||||
-- parser.lua — 订阅格式解析 → 统一节点模型(纯 Lua)
|
||||
-- luci-app-substore
|
||||
|
||||
local util = require("substore.util")
|
||||
local node = require("substore.node")
|
||||
local parser_yaml = require("substore.parser_yaml")
|
||||
local parser_clash_yaml = require("substore.parser_clash_yaml")
|
||||
local parser_json_config = require("substore.parser_json_config")
|
||||
local parser_surge = require("substore.parser_surge")
|
||||
|
||||
local M = {}
|
||||
|
||||
local SUPPORTED = {
|
||||
vmess = true, vless = true, trojan = true, ss = true, ssr = true,
|
||||
hysteria2 = true, tuic = true, wireguard = true,
|
||||
}
|
||||
|
||||
local function split_lines(content)
|
||||
local out = {}
|
||||
for line in content:gmatch("[^\r\n]+") do
|
||||
out[#out + 1] = line
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- 检测订阅格式:uri / base64 / json / yaml / empty / unknown
|
||||
function M.detect(content)
|
||||
content = util.trim(content or "")
|
||||
content = content:gsub("^\239\187\191", "") -- 去除 UTF-8 BOM(部分机场/CDN 会在开头塞 BOM)
|
||||
if content == "" then return "empty" end
|
||||
local stripped = content:gsub("%s+", "")
|
||||
if stripped:sub(1, 1) == "{" then return "json" end
|
||||
-- YAML 检测:包含 proxies: 或 outbounds: 键
|
||||
if content:match("^[%s]*proxies:") or content:match("^[%s]*outbounds:") then
|
||||
return "yaml"
|
||||
end
|
||||
-- 检查内容中是否包含 proxies: 或 outbounds: 行
|
||||
for line in content:gmatch("[^\r\n]+") do
|
||||
if line:match("^%s*proxies:%s*$") or line:match("^%s*outbounds:%s*$") then
|
||||
return "yaml"
|
||||
end
|
||||
end
|
||||
if stripped:find("vmess://", 1, true) or stripped:find("vless://", 1, true)
|
||||
or stripped:find("trojan://", 1, true) or stripped:find("ssr://", 1, true)
|
||||
or stripped:find("ss://", 1, true) or content:find("://", 1, true) then
|
||||
return "uri"
|
||||
end
|
||||
if stripped:match("^[A-Za-z0-9+/_%-]*=*$") and #stripped > 10 then
|
||||
-- 更严格:需含 = / + - _ 之一,或长度为 4 的整数倍(避免把纯字母数字文本误判为 base64)
|
||||
-- 兼容 base64url(- _ 无 padding):URL-safe 变体也常见于机场订阅
|
||||
if stripped:find("=", 1, true) or stripped:find("/", 1, true)
|
||||
or stripped:find("+", 1, true) or stripped:find("-", 1, true)
|
||||
or stripped:find("_", 1, true) or #stripped % 4 == 0 then
|
||||
return "base64"
|
||||
end
|
||||
-- 纯字母数字且长度非 4 倍数:可能是去掉 padding 的 base64url,尝试解码校验内容
|
||||
local decoded = util.base64_url_decode(stripped)
|
||||
if decoded ~= "" and (decoded:find("vmess://", 1, true) or decoded:find("vless://", 1, true)
|
||||
or decoded:find("trojan://", 1, true) or decoded:find("ss://", 1, true)
|
||||
or decoded:find("ssr://", 1, true)) then
|
||||
return "base64"
|
||||
end
|
||||
end
|
||||
if parser_surge.is_config(content) then return "surge" end
|
||||
return "unknown"
|
||||
end
|
||||
|
||||
-- ---------- 协议解析 ----------
|
||||
local function parse_ss(body)
|
||||
-- ss:// 兼容多种变体:base64(method:password@host:port)、method:password@host:port、
|
||||
-- base64(method:password)@host:port;可选 ?plugin=、#name
|
||||
local fragment, rest = "", body
|
||||
local hash = rest:find("#", 1, true)
|
||||
if hash then
|
||||
fragment = util.url_decode(rest:sub(hash + 1))
|
||||
rest = rest:sub(1, hash - 1)
|
||||
end
|
||||
local query = {}
|
||||
local qpos = rest:find("?", 1, true)
|
||||
local hp = rest
|
||||
if qpos then
|
||||
hp = rest:sub(1, qpos - 1)
|
||||
for k, v in rest:sub(qpos + 1):gmatch("([^&=]+)=([^&]*)") do
|
||||
query[k] = util.url_decode(v)
|
||||
end
|
||||
end
|
||||
if hp == "" then return nil, "bad ss" end
|
||||
|
||||
local userinfo, hostport
|
||||
local at = hp:find("@", 1, true)
|
||||
if at then
|
||||
userinfo, hostport = hp:sub(1, at - 1), hp:sub(at + 1)
|
||||
else
|
||||
-- 整体可能是 base64(method:password@host:port)
|
||||
local decoded = util.base64_decode(hp)
|
||||
local d = decoded:find("@", 1, true)
|
||||
if d then
|
||||
userinfo, hostport = decoded:sub(1, d - 1), decoded:sub(d + 1)
|
||||
else
|
||||
return nil, "bad ss (no @)"
|
||||
end
|
||||
end
|
||||
-- userinfo 可能是 base64(method:password)
|
||||
if not userinfo:find(":", 1, true) then
|
||||
userinfo = util.base64_decode(userinfo) or ""
|
||||
end
|
||||
local method, password = userinfo:match("^([^:]+):(.*)$")
|
||||
local host, port = util.split_hostport(hostport)
|
||||
if not (method and password and host) then return nil, "bad ss" end
|
||||
local name = fragment ~= "" and fragment or (host .. ":" .. tostring(port or ""))
|
||||
local out = node.normalize({
|
||||
proto = "shadowsocks", name = name, server = host, port = tonumber(port),
|
||||
method = method, password = password, raw = ("ss://" .. body),
|
||||
})
|
||||
if query.plugin then out.plugin = query.plugin end
|
||||
return out
|
||||
end
|
||||
|
||||
-- SSR 参数值可能为 base64url、标准 base64 或纯文本:优先 base64url 解码,失败回退 URL 解码
|
||||
local function b64u_decode(s)
|
||||
s = s or ""
|
||||
local v = util.base64_url_decode(s)
|
||||
if v ~= "" then return v end
|
||||
return util.url_decode(s)
|
||||
end
|
||||
|
||||
local function parse_ssr(body)
|
||||
-- ssr://base64(server:port:protocol:method:obfs:base64(password)/?obfsparam=..&protoparam=..&remarks=..&group=..)
|
||||
-- 外层为标准 base64;密码字段可为标准/base64url base64;obfsparam/protoparam/remarks/group 为 base64url
|
||||
if not body or body == "" then return nil, "bad ssr" end
|
||||
-- 容忍 #fragment 与 URL 转义(部分生成器会在末尾追加)
|
||||
local rest, frag = body, ""
|
||||
local hash = rest:find("#", 1, true)
|
||||
if hash then
|
||||
frag = util.url_decode(rest:sub(hash + 1))
|
||||
rest = rest:sub(1, hash - 1)
|
||||
end
|
||||
if rest:find("%", 1, true) then rest = util.url_decode(rest) end
|
||||
local decoded = util.base64_decode(rest)
|
||||
if decoded == "" then return nil, "bad ssr" end
|
||||
|
||||
-- 以首个 '?' 切分主体与参数(密码 base64 可能含 '/',故不能按 '/' 切分)
|
||||
local main, query = decoded:match("^([^?]*)%?(.*)$")
|
||||
if not main then main, query = decoded, "" end
|
||||
main = main:gsub("/+$", "")
|
||||
|
||||
local server, port, protocol, method, obfs, pwd_b64 = main:match("^([^:]*):([^:]*):([^:]*):([^:]*):([^:]*):(.*)$")
|
||||
if not server or server == "" then return nil, "bad ssr" end
|
||||
local password = util.base64_url_decode(pwd_b64 or "")
|
||||
if password == "" then password = pwd_b64 or "" end
|
||||
|
||||
local params = {}
|
||||
for k, v in (query or ""):gmatch("([^&=]+)=([^&]*)") do
|
||||
params[k] = v
|
||||
end
|
||||
|
||||
local name = b64u_decode(params.remarks or "")
|
||||
if name == "" then name = frag end
|
||||
if name == "" then name = server .. ":" .. tostring(port or "") end
|
||||
|
||||
local out = node.normalize({
|
||||
proto = "ssr", name = name, server = server, port = tonumber(port),
|
||||
method = method, password = password, protocol = protocol, obfs = obfs,
|
||||
raw = ("ssr://" .. body),
|
||||
})
|
||||
local op = b64u_decode(params.obfsparam or "")
|
||||
local pp = b64u_decode(params.protoparam or "")
|
||||
if op ~= "" then out.obfs_param = op end
|
||||
if pp ~= "" then out.protocol_param = pp end
|
||||
local grp = b64u_decode(params.group or "")
|
||||
if grp ~= "" then out.group = grp end
|
||||
return out
|
||||
end
|
||||
|
||||
local function parse_vless(uri, body)
|
||||
local name, rest = "", body
|
||||
local hash = rest:find("#", 1, true)
|
||||
if hash then
|
||||
name = util.url_decode(rest:sub(hash + 1))
|
||||
rest = rest:sub(1, hash - 1)
|
||||
end
|
||||
local query = {}
|
||||
local qpos = rest:find("?", 1, true)
|
||||
local hp = rest
|
||||
if qpos then
|
||||
hp = rest:sub(1, qpos - 1)
|
||||
for k, v in rest:sub(qpos + 1):gmatch("([^&=]+)=([^&]*)") do
|
||||
query[k] = util.url_decode(v)
|
||||
end
|
||||
end
|
||||
local at = hp:find("@", 1, true)
|
||||
if not at then return nil, "bad vless" end
|
||||
local uuid = hp:sub(1, at - 1)
|
||||
local host, port = util.split_hostport(hp:sub(at + 1))
|
||||
local out = node.normalize({
|
||||
proto = "vless", name = name, server = host, port = tonumber(port), uuid = uuid, raw = uri,
|
||||
})
|
||||
if query.type then out.net = query.type end
|
||||
if query.security then out.security = query.security end
|
||||
if query.sni then out.sni = query.sni end
|
||||
if query.fp then out.fp = query.fp end
|
||||
if query.alpn then out.alpn = query.alpn end
|
||||
if query.headerType then out.headerType = query.headerType end
|
||||
return out
|
||||
end
|
||||
|
||||
local function parse_trojan(uri, body)
|
||||
local name, rest = "", body
|
||||
local hash = rest:find("#", 1, true)
|
||||
if hash then
|
||||
name = util.url_decode(rest:sub(hash + 1))
|
||||
rest = rest:sub(1, hash - 1)
|
||||
end
|
||||
local query = {}
|
||||
local qpos = rest:find("?", 1, true)
|
||||
local hp = rest
|
||||
if qpos then
|
||||
hp = rest:sub(1, qpos - 1)
|
||||
for k, v in rest:sub(qpos + 1):gmatch("([^&=]+)=([^&]*)") do
|
||||
query[k] = util.url_decode(v)
|
||||
end
|
||||
end
|
||||
local at = hp:find("@", 1, true)
|
||||
if not at then return nil, "bad trojan" end
|
||||
local password = hp:sub(1, at - 1)
|
||||
local host, port = util.split_hostport(hp:sub(at + 1))
|
||||
local out = node.normalize({
|
||||
proto = "trojan", name = name, server = host, port = tonumber(port),
|
||||
password = password, raw = uri,
|
||||
})
|
||||
if query.sni then out.sni = query.sni end
|
||||
if query.security then out.security = query.security end
|
||||
if query.alpn then out.alpn = query.alpn end
|
||||
return out
|
||||
end
|
||||
|
||||
local function parse_vmess(uri, body)
|
||||
local name, rest = "", body
|
||||
local hash = rest:find("#", 1, true)
|
||||
if hash then
|
||||
name = util.url_decode(rest:sub(hash + 1))
|
||||
rest = rest:sub(1, hash - 1)
|
||||
end
|
||||
-- v2rayN 新格式:vmess://uuid@host:port?type=tcp&security=none#name
|
||||
if rest:find("@", 1, true) then
|
||||
local query = {}
|
||||
local qpos = rest:find("?", 1, true)
|
||||
local hp = rest
|
||||
if qpos then
|
||||
hp = rest:sub(1, qpos - 1)
|
||||
for k, v in rest:sub(qpos + 1):gmatch("([^&=]+)=([^&]*)") do
|
||||
query[k] = util.url_decode(v)
|
||||
end
|
||||
end
|
||||
local at = hp:find("@", 1, true)
|
||||
local uuid = hp:sub(1, at - 1)
|
||||
local host, port = util.split_hostport(hp:sub(at + 1))
|
||||
local out = node.normalize({
|
||||
proto = "vmess", name = name, server = host, port = tonumber(port),
|
||||
uuid = uuid, raw = uri,
|
||||
})
|
||||
if query.type then out.net = query.type end
|
||||
if query.security then out.security = query.security end
|
||||
if query.sni then out.sni = query.sni end
|
||||
return out
|
||||
end
|
||||
-- 经典格式:vmess://base64(json)
|
||||
local decoded = util.base64_url_decode(rest)
|
||||
if decoded == "" then return nil, "bad vmess b64" end
|
||||
local j = util.json_decode(decoded)
|
||||
if type(j) ~= "table" or not j.add then return nil, "bad vmess json" end
|
||||
local out = node.normalize({
|
||||
proto = "vmess",
|
||||
name = j.ps or (j.add .. ":" .. tostring(j.port)),
|
||||
server = j.add, port = tonumber(j.port),
|
||||
uuid = j.id, aid = tonumber(j.aid),
|
||||
net = j.net, type = j.type, security = j.scy or j.security, tls = j.tls,
|
||||
raw = uri,
|
||||
})
|
||||
return out
|
||||
end
|
||||
|
||||
-- hysteria2://password@host:port/?sni=..&insecure=..#name
|
||||
local function parse_hysteria2(uri, body)
|
||||
local name, rest = "", body
|
||||
local hash = rest:find("#", 1, true)
|
||||
if hash then
|
||||
name = util.url_decode(rest:sub(hash + 1))
|
||||
rest = rest:sub(1, hash - 1)
|
||||
end
|
||||
local query = {}
|
||||
local qpos = rest:find("?", 1, true)
|
||||
local hp = rest
|
||||
if qpos then
|
||||
hp = rest:sub(1, qpos - 1)
|
||||
for k, v in rest:sub(qpos + 1):gmatch("([^&=]+)=([^&]*)") do
|
||||
query[k] = util.url_decode(v)
|
||||
end
|
||||
end
|
||||
local at = hp:find("@", 1, true)
|
||||
if not at then return nil, "bad hysteria2" end
|
||||
local password = util.url_decode(hp:sub(1, at - 1))
|
||||
local host, port = util.split_hostport(hp:sub(at + 1))
|
||||
local out = node.normalize({
|
||||
proto = "hysteria2", name = name, server = host, port = tonumber(port),
|
||||
password = password, raw = uri,
|
||||
})
|
||||
if query.sni then out.sni = query.sni end
|
||||
if query.insecure ~= nil then out.insecure = query.insecure end
|
||||
-- 混淆参数回读,保证导出→导入回环不丢字段
|
||||
if query.obfs then out.obfs = query.obfs end
|
||||
if query["obfs-password"] then out["obfs-password"] = query["obfs-password"] end
|
||||
return out
|
||||
end
|
||||
|
||||
-- tuic://uuid:password@host:port/?congestion_control=..&alpn=..&sni=..#name
|
||||
local function parse_tuic(uri, body)
|
||||
local name, rest = "", body
|
||||
local hash = rest:find("#", 1, true)
|
||||
if hash then
|
||||
name = util.url_decode(rest:sub(hash + 1))
|
||||
rest = rest:sub(1, hash - 1)
|
||||
end
|
||||
local query = {}
|
||||
local qpos = rest:find("?", 1, true)
|
||||
local hp = rest
|
||||
if qpos then
|
||||
hp = rest:sub(1, qpos - 1)
|
||||
for k, v in rest:sub(qpos + 1):gmatch("([^&=]+)=([^&]*)") do
|
||||
query[k] = util.url_decode(v)
|
||||
end
|
||||
end
|
||||
local at = hp:find("@", 1, true)
|
||||
if not at then return nil, "bad tuic" end
|
||||
local userinfo = util.url_decode(hp:sub(1, at - 1))
|
||||
local uuid, password = "", userinfo
|
||||
local cpos = userinfo:find(":", 1, true)
|
||||
if cpos then
|
||||
uuid = userinfo:sub(1, cpos - 1)
|
||||
password = userinfo:sub(cpos + 1)
|
||||
end
|
||||
local host, port = util.split_hostport(hp:sub(at + 1))
|
||||
local out = node.normalize({
|
||||
proto = "tuic", name = name, server = host, port = tonumber(port),
|
||||
uuid = uuid, password = password, raw = uri,
|
||||
})
|
||||
if query.congestion_control then out.congestion_control = query.congestion_control end
|
||||
if query.alpn then out.alpn = query.alpn end
|
||||
if query.sni then out.sni = query.sni end
|
||||
return out
|
||||
end
|
||||
|
||||
-- wireguard://base64(json)#name —— 本项目自定义 scheme(wireguard 无统一 URI 标准)
|
||||
local function parse_wireguard(uri, body)
|
||||
local name, rest = "", body
|
||||
local hash = rest:find("#", 1, true)
|
||||
if hash then
|
||||
name = util.url_decode(rest:sub(hash + 1))
|
||||
rest = rest:sub(1, hash - 1)
|
||||
end
|
||||
local decoded = util.base64_decode(rest)
|
||||
if decoded == "" then return nil, "bad wireguard" end
|
||||
local j = util.json_decode(decoded)
|
||||
if type(j) ~= "table" or not j.server or not j.port then return nil, "bad wireguard json" end
|
||||
local out = node.normalize({
|
||||
proto = "wireguard", name = name, server = j.server, port = tonumber(j.port),
|
||||
["private-key"] = j["private-key"] or j.private_key,
|
||||
["peer-public-key"] = j["peer-public-key"] or j.peer_public_key,
|
||||
["preshared-key"] = j["preshared-key"] or j.preshared_key,
|
||||
raw = uri,
|
||||
})
|
||||
if j.mtu then out.mtu = tonumber(j.mtu) end
|
||||
if out.name == "" and j.name then out.name = j.name end
|
||||
return out
|
||||
end
|
||||
|
||||
-- 解析单条节点 URI,返回节点表或 nil, err
|
||||
function M.parse_uri(uri)
|
||||
uri = util.trim(uri)
|
||||
local proto, body = uri:match("^([%w]+)://(.*)$")
|
||||
if not proto then return nil, "no scheme" end
|
||||
proto = proto:lower()
|
||||
if not SUPPORTED[proto] then return nil, "unsupported proto " .. proto end
|
||||
if proto == "ss" then return parse_ss(body) end
|
||||
if proto == "ssr" then return parse_ssr(body) end
|
||||
if proto == "vless" then return parse_vless(uri, body) end
|
||||
if proto == "trojan" then return parse_trojan(uri, body) end
|
||||
if proto == "vmess" then return parse_vmess(uri, body) end
|
||||
if proto == "hysteria2" then return parse_hysteria2(uri, body) end
|
||||
if proto == "tuic" then return parse_tuic(uri, body) end
|
||||
if proto == "wireguard" then return parse_wireguard(uri, body) end
|
||||
return nil, "unsupported"
|
||||
end
|
||||
|
||||
local function parse_lines(lines)
|
||||
local nodes = {}
|
||||
for _, line in ipairs(lines) do
|
||||
line = util.trim(line)
|
||||
if line ~= "" and line:find("://", 1, true) then
|
||||
local n = M.parse_uri(line)
|
||||
if n then nodes[#nodes + 1] = n end
|
||||
end
|
||||
end
|
||||
return nodes
|
||||
end
|
||||
|
||||
local function parse_json_content(content)
|
||||
local data = util.json_decode(content)
|
||||
if type(data) ~= "table" then return nil, "JSON 解析失败" end
|
||||
|
||||
-- 客户端配置文件分发:sing-box / V2Ray / Clash JSON
|
||||
if type(data.outbounds) == "table" then
|
||||
local first = data.outbounds[1]
|
||||
if type(first) == "table" and first.protocol then
|
||||
return parser_json_config.parse_v2ray_json(content), nil
|
||||
end
|
||||
return parser_json_config.parse_singbox_json(content), nil
|
||||
end
|
||||
if type(data.proxies) == "table" then
|
||||
return parser_json_config.parse_clash_json(content), nil
|
||||
end
|
||||
|
||||
-- 通用节点数组 / 单节点对象
|
||||
local list
|
||||
if data[1] then list = data else list = { data } end
|
||||
local nodes = {}
|
||||
for _, o in ipairs(list) do
|
||||
if type(o) == "table" and o.server and o.port then
|
||||
nodes[#nodes + 1] = node.normalize({
|
||||
proto = o.proto or "vmess",
|
||||
name = o.name or (o.server .. ":" .. o.port),
|
||||
server = o.server, port = tonumber(o.port),
|
||||
uuid = o.uuid, password = o.password, method = o.method,
|
||||
net = o.net, security = o.security, sni = o.sni,
|
||||
raw = o.raw,
|
||||
})
|
||||
end
|
||||
end
|
||||
return nodes, nil
|
||||
end
|
||||
|
||||
-- 简易 YAML 解析器:处理 Clash YAML proxies 格式
|
||||
-- 支持基本键值对和列表项,不处理复杂嵌套
|
||||
local function parse_yaml_content(content)
|
||||
local raw_nodes = {}
|
||||
local lines = split_lines(content)
|
||||
local current_node = nil
|
||||
local in_proxies = false
|
||||
local proxies_indent = 0
|
||||
|
||||
for i, line in ipairs(lines) do
|
||||
local trimmed = util.trim(line)
|
||||
if trimmed == "" or trimmed:sub(1, 1) == "#" then
|
||||
-- 跳过空行和注释
|
||||
elseif trimmed:match("^proxies:%s*$") or trimmed:match("^outbounds:%s*$") then
|
||||
in_proxies = true
|
||||
proxies_indent = line:match("^%s*") and #line:match("^%s*") or 0
|
||||
current_node = nil
|
||||
elseif in_proxies then
|
||||
-- 检测列表项开始:- name: xxx 或单独的 -
|
||||
local list_match = line:match("^%s*%-%s*(.*)$")
|
||||
if list_match then
|
||||
-- 保存上一个节点
|
||||
if current_node then
|
||||
raw_nodes[#raw_nodes + 1] = current_node
|
||||
end
|
||||
current_node = {}
|
||||
-- 检查列表项同一行是否有字段
|
||||
if list_match:match("^([^:]+):%s*(.+)$") then
|
||||
local k, v = list_match:match("^([^:]+):%s*(.+)$")
|
||||
current_node[k] = util.trim(v)
|
||||
end
|
||||
else
|
||||
-- 解析缩进的字段
|
||||
local indent = line:match("^%s*") and #line:match("^%s*") or 0
|
||||
if current_node and indent > proxies_indent then
|
||||
local k, v = trimmed:match("^([^:]+):%s*(.+)$")
|
||||
if k then
|
||||
k = util.trim(k)
|
||||
v = util.trim(v)
|
||||
-- 去掉引号
|
||||
v = v:gsub('^["\'](.*)["\']$', '%1')
|
||||
current_node[k] = v
|
||||
end
|
||||
else
|
||||
-- 遇到缩进减少,结束当前节点
|
||||
if current_node then
|
||||
raw_nodes[#raw_nodes + 1] = current_node
|
||||
current_node = nil
|
||||
end
|
||||
in_proxies = false
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- 保存最后一个节点
|
||||
if current_node then
|
||||
raw_nodes[#raw_nodes + 1] = current_node
|
||||
end
|
||||
|
||||
-- 协议映射:Clash type -> proto
|
||||
local proto_map = {
|
||||
vmess = "vmess",
|
||||
vless = "vless",
|
||||
trojan = "trojan",
|
||||
ss = "shadowsocks",
|
||||
ssr = "ssr",
|
||||
http = "http",
|
||||
socks5 = "socks5",
|
||||
}
|
||||
|
||||
-- 转换字段名并归一化
|
||||
local result = {}
|
||||
for _, n in ipairs(raw_nodes) do
|
||||
if not n or not n.server or not n.port then
|
||||
-- 跳过无效节点
|
||||
else
|
||||
local proto = proto_map[n.type or n.proto] or "vmess"
|
||||
local node_data = {
|
||||
proto = proto,
|
||||
name = n.name or n.Name or (n.server .. ":" .. tostring(n.port or "")),
|
||||
server = n.server,
|
||||
port = tonumber(n.port),
|
||||
uuid = n.uuid or n.id,
|
||||
password = n.password,
|
||||
method = n.cipher or n.method,
|
||||
net = n.network or n.net,
|
||||
security = n.tls or n.security,
|
||||
sni = n.sni or n.servername,
|
||||
alterId = tonumber(n.alterId),
|
||||
}
|
||||
result[#result + 1] = node.normalize(node_data)
|
||||
end
|
||||
end
|
||||
|
||||
return result
|
||||
end
|
||||
|
||||
function M.parse_yaml(content)
|
||||
-- 优先使用完整 Clash YAML 解析器;失败/为空则回退简易解析
|
||||
local nodes = parser_clash_yaml.parse(content)
|
||||
if nodes and #nodes > 0 then return nodes end
|
||||
return parse_yaml_content(content)
|
||||
end
|
||||
|
||||
-- ---------- 局域网订阅链接 ----------
|
||||
|
||||
-- 判断主机是否为内网 / 回环 / 链路本地地址(SSRF 防护)
|
||||
local function is_private_host(host)
|
||||
if not host then return false end
|
||||
host = host:lower()
|
||||
if host == "localhost" or host == "::" then return true end
|
||||
-- IPv6:去掉方括号
|
||||
local v6 = host:match("^%[([^%]]+)%]$")
|
||||
if v6 then host = v6 end
|
||||
if host:find(":", 1, true) then
|
||||
local h = host:gsub("^0*", "")
|
||||
if h == "" or h == "1" then return true end -- :: 或 ::1
|
||||
return host:match("^::") ~= nil
|
||||
or host:match("^f[cd]") ~= nil
|
||||
or host:match("^fe[89ab]") ~= nil
|
||||
end
|
||||
local a, b, c, d = host:match("^(%d+)%.(%d+)%.(%d+)%.(%d+)$")
|
||||
if not a then return false end
|
||||
a, b, c, d = tonumber(a), tonumber(b), tonumber(c), tonumber(d)
|
||||
if a == 0 or a == 127 or a == 10 then return true end
|
||||
if a == 100 and b >= 64 and b <= 127 then return true end -- CGNAT 100.64/10
|
||||
if a == 192 and b == 168 then return true end
|
||||
if a == 172 and b >= 16 and b <= 31 then return true end
|
||||
if a == 169 and b == 254 then return true end
|
||||
return false
|
||||
end
|
||||
|
||||
-- 检测是否为局域网订阅链接(http/https + 内网主机)
|
||||
function M.detect_local_link(url)
|
||||
url = util.trim(url or "")
|
||||
local scheme, rest = url:match("^(%a+)://(.*)$")
|
||||
if not scheme then return nil end
|
||||
scheme = scheme:lower()
|
||||
if scheme ~= "http" and scheme ~= "https" then return nil end
|
||||
local authority = rest:match("^([^/]*)") or ""
|
||||
if authority == "" then return nil end
|
||||
-- 去除 userinfo@
|
||||
if authority:find("@", 1, true) then
|
||||
authority = authority:match("^.*@(.*)$") or ""
|
||||
end
|
||||
local host
|
||||
if authority:sub(1, 1) == "[" then
|
||||
host = authority:match("^(%[.-%])")
|
||||
else
|
||||
host = authority:match("^([^:]+)")
|
||||
end
|
||||
if is_private_host(host) then return true end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- 解析局域网订阅链接,返回 { scheme, host, port, path, target, name, user }(host/port/path 为字符串;缺失为 nil)
|
||||
function M.parse_local_link(url)
|
||||
url = util.trim(url or "")
|
||||
local scheme, rest = url:match("^(%a+)://(.*)$")
|
||||
if not scheme then return nil end
|
||||
scheme = scheme:lower()
|
||||
|
||||
local authority, remainder = rest:match("^([^/]*)(.*)$")
|
||||
if authority == nil then authority, remainder = rest, "" end
|
||||
|
||||
-- 提取 host 与 port
|
||||
local host, port
|
||||
if authority:sub(1, 1) == "[" then
|
||||
host = authority:match("^%[([^%]]+)%]")
|
||||
port = authority:match("^%[[^%]]+%]:(%d+)")
|
||||
else
|
||||
host = authority:match("^([^:]+)")
|
||||
port = authority:match("^[^:]+:(%d+)")
|
||||
end
|
||||
|
||||
-- 拆分 path 与 query
|
||||
local path, query = remainder:match("^([^?]*)(.*)$")
|
||||
if path == nil then path, query = remainder, "" end
|
||||
|
||||
local params = {}
|
||||
if query and query ~= "" then
|
||||
for k, v in query:sub(2):gmatch("([^&=]+)=([^&]*)") do
|
||||
params[k] = util.url_decode(v)
|
||||
end
|
||||
end
|
||||
|
||||
-- name:优先 ?name=;否则取路径最后一段
|
||||
local name = params.name
|
||||
if not name or name == "" then
|
||||
name = path and path:match("/([^/]+)$")
|
||||
end
|
||||
|
||||
-- user:优先 ?uid= / ?user=;否则取 /<user>/download/ 路径中的用户名
|
||||
local user = params.uid or params.user
|
||||
if not user or user == "" then
|
||||
user = path and path:match("/([^/]+)/download/")
|
||||
end
|
||||
|
||||
return {
|
||||
scheme = scheme, host = host, port = port, path = path,
|
||||
target = params.target, name = name, user = user,
|
||||
}
|
||||
end
|
||||
|
||||
-- 解析订阅内容,返回 { nodes = {...}, format = "..." } 或 nil, err
|
||||
function M.parse(content)
|
||||
if not content or content == "" then return { nodes = {}, format = "empty" } end
|
||||
local format = M.detect(content)
|
||||
if format == "uri" then
|
||||
return { nodes = parse_lines(split_lines(content)), format = "uri" }
|
||||
elseif format == "base64" then
|
||||
-- 兼容标准 base64 与 base64url(- _ 无 padding):base64_url_decode 两者皆可
|
||||
local decoded = util.base64_url_decode(content)
|
||||
if decoded == "" then return nil, "Base64 解码失败" end
|
||||
return { nodes = parse_lines(split_lines(decoded)), format = "base64" }
|
||||
elseif format == "json" then
|
||||
local nodes, err = parse_json_content(content)
|
||||
if not nodes then return nil, err end
|
||||
return { nodes = nodes, format = "json" }
|
||||
elseif format == "yaml" then
|
||||
local nodes = M.parse_yaml(content)
|
||||
return { nodes = nodes, format = "yaml" }
|
||||
elseif format == "surge" then
|
||||
local nodes = parser_surge.parse(content)
|
||||
return { nodes = nodes, format = "surge" }
|
||||
end
|
||||
return nil, "无法识别的订阅格式"
|
||||
end
|
||||
|
||||
-- 本地订阅解析:支持文本模式和表单模式
|
||||
function M.parse_local(content, mode)
|
||||
mode = mode or "text"
|
||||
if not content or content == "" then return { nodes = {}, format = "empty" } end
|
||||
if mode == "form" then
|
||||
-- 表单模式:content 为 JSON 数组
|
||||
local data = util.json_decode(content)
|
||||
if type(data) ~= "table" then return nil, "表单数据解析失败" end
|
||||
local node_mod = require("substore.node")
|
||||
local nodes = {}
|
||||
for _, item in ipairs(data) do
|
||||
if type(item) == "table" then
|
||||
-- 透传表单全部字段,仅做协议/别名/类型修正,避免白名单丢字段
|
||||
local n = {}
|
||||
for k, v in pairs(item) do n[k] = v end
|
||||
n.proto = item.type or item.proto or "vmess"
|
||||
n.type = nil
|
||||
if n.net == nil and n.network ~= nil then n.net = n.network end
|
||||
n.network = nil
|
||||
n.port = tonumber(n.port) or n.port
|
||||
if n.alterId ~= nil then n.alterId = tonumber(n.alterId) or n.alterId end
|
||||
-- 字符串布尔转布尔值
|
||||
for _, bk in ipairs({ "udp", "skip-cert-verify", "skip_cert_verify" }) do
|
||||
if type(n[bk]) == "string" then
|
||||
n[bk] = (n[bk] == "true" or n[bk] == "1")
|
||||
end
|
||||
end
|
||||
if n["skip-cert-verify"] ~= nil and n.skip_cert_verify == nil then
|
||||
n.skip_cert_verify = n["skip-cert-verify"]
|
||||
end
|
||||
-- shadowsocks 用 method;vmess/ssr 用 cipher,互为别名
|
||||
if n.method == nil and n.cipher ~= nil then n.method = n.cipher end
|
||||
if n.cipher == nil and n.method ~= nil then n.cipher = n.method end
|
||||
-- SSR 参数别名
|
||||
if n.obfs_param == nil and n["obfs-param"] ~= nil then n.obfs_param = n["obfs-param"] end
|
||||
if n.protocol_param == nil and n["protocol-param"] ~= nil then n.protocol_param = n["protocol-param"] end
|
||||
if n.name == nil or n.name == "" then
|
||||
n.name = (n.server or "") .. ":" .. tostring(n.port or "")
|
||||
end
|
||||
nodes[#nodes + 1] = node_mod.normalize(n)
|
||||
end
|
||||
end
|
||||
return { nodes = nodes, format = "local-form" }
|
||||
end
|
||||
-- 文本模式:使用通用解析
|
||||
return M.parse(content)
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -0,0 +1,197 @@
|
||||
-- parser_clash_yaml.lua — Clash YAML 解析 → 统一节点模型(纯 Lua 5.1)
|
||||
-- luci-app-substore
|
||||
-- 内置极简缩进式 YAML 解析器,处理 Clash proxies / proxy-groups 结构
|
||||
|
||||
local util = require("substore.util")
|
||||
local node = require("substore.node")
|
||||
|
||||
local M = {}
|
||||
|
||||
-- Clash type → 统一 proto
|
||||
local TYPE_MAP = {
|
||||
vmess = "vmess",
|
||||
vless = "vless",
|
||||
trojan = "trojan",
|
||||
ss = "shadowsocks",
|
||||
shadowsocks = "shadowsocks",
|
||||
socks5 = "socks",
|
||||
socks = "socks",
|
||||
hysteria2 = "hysteria2",
|
||||
hysteria = "hysteria",
|
||||
tuic = "tuic",
|
||||
wireguard = "wireguard",
|
||||
ssr = "ssr",
|
||||
http = "http",
|
||||
}
|
||||
|
||||
-- 标量值转换:布尔 / 数字 / 去引号 / 保留字符串
|
||||
local function scalar(raw)
|
||||
raw = raw or ""
|
||||
raw = raw:gsub("%s*$", "")
|
||||
if raw == "true" or raw == "True" or raw == "TRUE" then return true end
|
||||
if raw == "false" or raw == "False" or raw == "FALSE" then return false end
|
||||
if raw == "null" or raw == "~" or raw == "" then return nil end
|
||||
raw = raw:gsub('^"(.*)"$', "%1"):gsub("^'(.*)'$", "%1")
|
||||
local n = tonumber(raw)
|
||||
if n then return n end
|
||||
return raw
|
||||
end
|
||||
|
||||
-- 预处理内容为 (indent, rest) 列表,跳过空行、注释、文档分隔符
|
||||
local function preprocess(content)
|
||||
local items = {}
|
||||
for l in (content:gsub("\r\n", "\n")):gmatch("[^\n]+") do
|
||||
local s = l
|
||||
local ind = 0
|
||||
while s:sub(1, 1) == " " do ind = ind + 1; s = s:sub(2) end
|
||||
local t = s:gsub("%s*$", "")
|
||||
if t ~= "" and not t:match("^#") and t ~= "---" and t ~= "..." then
|
||||
items[#items + 1] = { ind = ind, rest = t }
|
||||
end
|
||||
end
|
||||
return items
|
||||
end
|
||||
|
||||
local function parse_yaml(content)
|
||||
local items = preprocess(content)
|
||||
local pos = 1
|
||||
local n_items = #items
|
||||
|
||||
local read_list
|
||||
local function read_map(min_indent)
|
||||
local map = {}
|
||||
while pos <= n_items do
|
||||
local it = items[pos]
|
||||
if it.ind < min_indent then break end
|
||||
if it.rest == "-" or it.rest:match("^-%s") then break end
|
||||
local k, v = it.rest:match("^([^:]+):%s*(.*)$")
|
||||
if not k then
|
||||
pos = pos + 1
|
||||
else
|
||||
k = k:gsub("%s*$", "")
|
||||
pos = pos + 1
|
||||
if v == "" or v == "|" or v == ">" then
|
||||
if pos <= n_items and items[pos].ind > it.ind then
|
||||
local nxt = items[pos]
|
||||
if nxt.rest == "-" or nxt.rest:match("^-%s") then
|
||||
map[k] = read_list(nxt.ind)
|
||||
else
|
||||
map[k] = read_map(nxt.ind)
|
||||
end
|
||||
else
|
||||
map[k] = {}
|
||||
end
|
||||
else
|
||||
map[k] = scalar(v)
|
||||
end
|
||||
end
|
||||
end
|
||||
return map
|
||||
end
|
||||
|
||||
read_list = function(min_indent)
|
||||
local list = {}
|
||||
while pos <= n_items do
|
||||
local it = items[pos]
|
||||
if it.ind < min_indent then break end
|
||||
local dash
|
||||
if it.rest == "-" then dash = "" else dash = it.rest:match("^-%s*(.*)$") end
|
||||
if dash == nil then break end
|
||||
pos = pos + 1
|
||||
|
||||
local m = nil
|
||||
-- 流式 JSON 对象:- {"name":"...","type":"vmess",...}(机场 clash 配置常见写法)
|
||||
if dash:sub(1, 1) == "{" then
|
||||
local obj = util.json_decode(dash)
|
||||
if type(obj) == "table" then m = obj end
|
||||
else
|
||||
local k, v = dash:match("^([^:]+):%s*(.*)$")
|
||||
if k and v ~= "" then
|
||||
m = { [k:gsub("%s*$", "")] = scalar(v) }
|
||||
end
|
||||
end
|
||||
|
||||
if pos <= n_items and items[pos].ind > it.ind then
|
||||
local nxt = items[pos]
|
||||
if nxt.rest == "-" or nxt.rest:match("^-%s") then
|
||||
list[#list + 1] = m or (dash ~= "" and scalar(dash) or nil)
|
||||
else
|
||||
local sub = read_map(nxt.ind)
|
||||
if m then
|
||||
for sk, sv in pairs(sub) do m[sk] = sv end
|
||||
list[#list + 1] = m
|
||||
else
|
||||
list[#list + 1] = sub
|
||||
end
|
||||
end
|
||||
elseif m or dash ~= "" then
|
||||
list[#list + 1] = m or scalar(dash)
|
||||
end
|
||||
end
|
||||
return list
|
||||
end
|
||||
|
||||
local doc = read_map(0)
|
||||
return doc
|
||||
end
|
||||
|
||||
-- 将单个 Clash proxy 表映射为统一节点模型
|
||||
local function map_clash_node(p)
|
||||
if type(p) ~= "table" then return nil end
|
||||
if not p.name or not p.server or not p.port then return nil end
|
||||
|
||||
local proto = TYPE_MAP[p.type] or p.type or "vmess"
|
||||
local n = {
|
||||
proto = proto,
|
||||
name = p.name,
|
||||
server = p.server,
|
||||
port = tonumber(p.port),
|
||||
uuid = p.uuid or p.id,
|
||||
password = p.password,
|
||||
method = p.cipher or p.method,
|
||||
net = p.network or p.net,
|
||||
sni = p.sni or p.servername,
|
||||
tls = p.tls,
|
||||
fp = p.fp,
|
||||
}
|
||||
if p.alpn then n.alpn = p.alpn end
|
||||
if p.udp ~= nil then n.udp = p.udp end
|
||||
if p["skip-cert-verify"] ~= nil then
|
||||
local sk = p["skip-cert-verify"]
|
||||
n["skip-cert-verify"] = sk
|
||||
n.skip_cert_verify = sk
|
||||
end
|
||||
if p.alterId then n.alterId = tonumber(p.alterId) end
|
||||
if p.security then n.security = p.security end
|
||||
if p.flow then n.flow = p.flow end
|
||||
-- ws-opts(Clash 的 ws 传输参数)映射到统一模型的 path / host
|
||||
if type(p["ws-opts"]) == "table" then
|
||||
if p["ws-opts"].path then n.path = p["ws-opts"].path end
|
||||
local wh = p["ws-opts"].headers
|
||||
if type(wh) == "table" and wh.Host then n.host = wh.Host end
|
||||
end
|
||||
|
||||
-- 保留其余字段(wireguard 的 private-key、peer-public-key 等)
|
||||
for k, v in pairs(p) do
|
||||
if n[k] == nil then n[k] = v end
|
||||
end
|
||||
|
||||
return node.normalize(n)
|
||||
end
|
||||
|
||||
-- 解析 Clash YAML,返回统一节点模型列表
|
||||
function M.parse(content)
|
||||
if not content or content == "" then return {} end
|
||||
local doc = parse_yaml(content)
|
||||
local proxies = doc and doc.proxies
|
||||
if type(proxies) ~= "table" then return {} end
|
||||
|
||||
local nodes = {}
|
||||
for _, p in ipairs(proxies) do
|
||||
local n = map_clash_node(p)
|
||||
if n then nodes[#nodes + 1] = n end
|
||||
end
|
||||
return nodes
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -0,0 +1,269 @@
|
||||
-- parser_json_config.lua — JSON 配置解析 → 统一节点模型(纯 Lua)
|
||||
-- luci-app-substore
|
||||
-- 支持 Sing-box、V2Ray、Clash JSON 配置解析(Lua 5.1 兼容,无 goto)
|
||||
|
||||
local util = require("substore.util")
|
||||
local node = require("substore.node")
|
||||
|
||||
local M = {}
|
||||
|
||||
-- 协议映射(与 parser_clash_yaml 的 TYPE_MAP 保持一致)
|
||||
local proto_map = {
|
||||
vmess = "vmess",
|
||||
vless = "vless",
|
||||
trojan = "trojan",
|
||||
shadowsocks = "shadowsocks",
|
||||
ss = "shadowsocks",
|
||||
socks = "socks",
|
||||
socks5 = "socks",
|
||||
http = "http",
|
||||
hysteria2 = "hysteria2",
|
||||
hysteria = "hysteria",
|
||||
tuic = "tuic",
|
||||
wireguard = "wireguard",
|
||||
ssr = "ssr",
|
||||
}
|
||||
|
||||
local SUPPORTED = {
|
||||
vmess = true, vless = true, trojan = true, shadowsocks = true,
|
||||
hysteria2 = true, hysteria = true, tuic = true, wireguard = true,
|
||||
socks = true, http = true, ssr = true,
|
||||
}
|
||||
|
||||
-- ---------- Sing-box JSON 解析 ----------
|
||||
function M.parse_singbox_json(content)
|
||||
if not content or content == "" then return {} end
|
||||
|
||||
local data = util.json_decode(content)
|
||||
if type(data) ~= "table" then return {} end
|
||||
|
||||
local nodes = {}
|
||||
local outbounds = data.outbounds or {}
|
||||
|
||||
for _, outbound in ipairs(outbounds) do
|
||||
if type(outbound) == "table" then
|
||||
local proto = proto_map[outbound.type] or outbound.type
|
||||
if proto and SUPPORTED[proto] then
|
||||
local server = outbound.server
|
||||
local port = outbound.server_port
|
||||
if server and port then
|
||||
local node_data = {
|
||||
proto = proto,
|
||||
name = outbound.tag or (server .. ":" .. tostring(port)),
|
||||
server = server,
|
||||
port = tonumber(port),
|
||||
}
|
||||
|
||||
if proto == "vmess" or proto == "vless" then
|
||||
node_data.uuid = outbound.uuid
|
||||
if outbound.flow then node_data.flow = outbound.flow end
|
||||
elseif proto == "trojan" then
|
||||
node_data.password = outbound.password
|
||||
elseif proto == "shadowsocks" then
|
||||
node_data.method = outbound.method
|
||||
node_data.password = outbound.password
|
||||
elseif proto == "hysteria2" or proto == "hysteria" then
|
||||
node_data.password = outbound.password or outbound.auth_str or outbound.auth
|
||||
elseif proto == "tuic" then
|
||||
node_data.uuid = outbound.uuid
|
||||
node_data.password = outbound.password
|
||||
if outbound.congestion_control then node_data.congestion_control = outbound.congestion_control end
|
||||
elseif proto == "wireguard" then
|
||||
node_data["private-key"] = outbound["private-key"] or outbound.private_key
|
||||
node_data["peer-public-key"] = outbound["peer-public-key"] or outbound.peer_public_key
|
||||
node_data["preshared-key"] = outbound["preshared-key"] or outbound.preshared_key
|
||||
if outbound.mtu then node_data.mtu = outbound.mtu end
|
||||
elseif proto == "socks" or proto == "http" then
|
||||
node_data.username = outbound.username
|
||||
node_data.password = outbound.password
|
||||
end
|
||||
|
||||
if outbound.tls and type(outbound.tls) == "table" then
|
||||
if outbound.tls.server_name then
|
||||
node_data.sni = outbound.tls.server_name
|
||||
end
|
||||
if outbound.tls.enabled ~= nil then
|
||||
node_data.tls = outbound.tls.enabled
|
||||
end
|
||||
end
|
||||
|
||||
if outbound.security then
|
||||
node_data.security = outbound.security
|
||||
end
|
||||
if outbound.network then
|
||||
node_data.net = outbound.network
|
||||
end
|
||||
|
||||
nodes[#nodes + 1] = node.normalize(node_data)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return nodes
|
||||
end
|
||||
|
||||
-- ---------- V2Ray JSON 解析 ----------
|
||||
function M.parse_v2ray_json(content)
|
||||
if not content or content == "" then return {} end
|
||||
|
||||
local data = util.json_decode(content)
|
||||
if type(data) ~= "table" then return {} end
|
||||
|
||||
local nodes = {}
|
||||
local outbounds = data.outbounds or {}
|
||||
|
||||
for _, outbound in ipairs(outbounds) do
|
||||
if type(outbound) == "table" then
|
||||
local proto = proto_map[outbound.protocol] or outbound.protocol
|
||||
if proto and SUPPORTED[proto] then
|
||||
local settings = outbound.settings
|
||||
if type(settings) == "table" then
|
||||
local server, port, uuid, password, method, flow, username
|
||||
|
||||
if proto == "vmess" or proto == "vless" then
|
||||
local vnext = settings.vnext
|
||||
if type(vnext) == "table" and #vnext > 0 then
|
||||
local sc = vnext[1]
|
||||
server = sc.address
|
||||
port = sc.port
|
||||
if type(sc.users) == "table" and #sc.users > 0 then
|
||||
local user = sc.users[1]
|
||||
uuid = user.id
|
||||
if user.flow then flow = user.flow end
|
||||
end
|
||||
end
|
||||
elseif proto == "trojan" then
|
||||
local servers = settings.servers
|
||||
if type(servers) == "table" and #servers > 0 then
|
||||
server = servers[1].address
|
||||
port = servers[1].port
|
||||
password = servers[1].password
|
||||
end
|
||||
elseif proto == "shadowsocks" then
|
||||
local servers = settings.servers
|
||||
if type(servers) == "table" and #servers > 0 then
|
||||
server = servers[1].address
|
||||
port = servers[1].port
|
||||
method = servers[1].method
|
||||
password = servers[1].password
|
||||
end
|
||||
elseif proto == "socks" or proto == "http" then
|
||||
local servers = settings.servers
|
||||
if type(servers) == "table" and #servers > 0 then
|
||||
server = servers[1].address
|
||||
port = servers[1].port
|
||||
if type(servers[1].users) == "table" and #servers[1].users > 0 then
|
||||
username = servers[1].users[1].user
|
||||
password = servers[1].users[1].pass
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if server and port then
|
||||
local node_data = {
|
||||
proto = proto,
|
||||
name = outbound.tag or (server .. ":" .. tostring(port)),
|
||||
server = server,
|
||||
port = tonumber(port),
|
||||
uuid = uuid,
|
||||
password = password,
|
||||
method = method,
|
||||
flow = flow,
|
||||
username = username,
|
||||
}
|
||||
|
||||
local ss2 = outbound.streamSettings
|
||||
if type(ss2) == "table" then
|
||||
if ss2.network then node_data.net = ss2.network end
|
||||
if ss2.security then node_data.security = ss2.security end
|
||||
if ss2.tlsSettings and type(ss2.tlsSettings) == "table" then
|
||||
if ss2.tlsSettings.serverName then node_data.sni = ss2.tlsSettings.serverName end
|
||||
end
|
||||
if ss2.realitySettings and type(ss2.realitySettings) == "table" then
|
||||
if ss2.realitySettings.serverName then node_data.sni = ss2.realitySettings.serverName end
|
||||
end
|
||||
end
|
||||
|
||||
nodes[#nodes + 1] = node.normalize(node_data)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return nodes
|
||||
end
|
||||
|
||||
-- ---------- Clash JSON 解析 ----------
|
||||
function M.parse_clash_json(content)
|
||||
if not content or content == "" then return {} end
|
||||
|
||||
local data = util.json_decode(content)
|
||||
if type(data) ~= "table" then return {} end
|
||||
|
||||
local nodes = {}
|
||||
local proxies = data.proxies or {}
|
||||
|
||||
for _, proxy in ipairs(proxies) do
|
||||
if type(proxy) == "table" then
|
||||
local proto = proto_map[proxy.type] or proxy.type
|
||||
if proto and SUPPORTED[proto] then
|
||||
local server = proxy.server
|
||||
local port = proxy.port
|
||||
if server and port then
|
||||
local node_data = {
|
||||
proto = proto,
|
||||
name = proxy.name or (server .. ":" .. tostring(port)),
|
||||
server = server,
|
||||
port = tonumber(port),
|
||||
}
|
||||
|
||||
if proto == "vmess" or proto == "vless" then
|
||||
node_data.uuid = proxy.uuid
|
||||
if proxy.alterId then node_data.alterId = tonumber(proxy.alterId) end
|
||||
elseif proto == "trojan" then
|
||||
node_data.password = proxy.password
|
||||
elseif proto == "shadowsocks" then
|
||||
node_data.method = proxy.cipher or proxy.method
|
||||
node_data.password = proxy.password
|
||||
elseif proto == "hysteria2" or proto == "hysteria" then
|
||||
node_data.password = proxy.password or proxy["auth-str"] or proxy.auth_str
|
||||
elseif proto == "tuic" then
|
||||
node_data.uuid = proxy.uuid
|
||||
node_data.password = proxy.password
|
||||
elseif proto == "wireguard" then
|
||||
node_data["private-key"] = proxy["private-key"] or proxy.private_key
|
||||
node_data["peer-public-key"] = proxy["peer-public-key"] or proxy.peer_public_key
|
||||
node_data["preshared-key"] = proxy["preshared-key"] or proxy.preshared_key
|
||||
if proxy.mtu then node_data.mtu = proxy.mtu end
|
||||
elseif proto == "socks" or proto == "http" then
|
||||
node_data.username = proxy.username
|
||||
node_data.password = proxy.password
|
||||
elseif proto == "ssr" then
|
||||
node_data.method = proxy.cipher or proxy.method
|
||||
node_data.password = proxy.password
|
||||
node_data.protocol = proxy.protocol
|
||||
node_data.obfs = proxy.obfs
|
||||
if proxy["obfs-param"] then node_data.obfs_param = proxy["obfs-param"] end
|
||||
if proxy["protocol-param"] then node_data.protocol_param = proxy["protocol-param"] end
|
||||
end
|
||||
|
||||
if proxy.network then node_data.net = proxy.network end
|
||||
if proxy.sni then
|
||||
node_data.sni = proxy.sni
|
||||
elseif proxy.servername then
|
||||
node_data.sni = proxy.servername
|
||||
end
|
||||
if proxy.tls then node_data.tls = proxy.tls end
|
||||
|
||||
nodes[#nodes + 1] = node.normalize(node_data)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return nodes
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -0,0 +1,131 @@
|
||||
-- parser_surge.lua — Surge 系 / Loon / QX 配置文件解析 → 统一节点模型(纯 Lua 5.1)
|
||||
-- luci-app-substore
|
||||
|
||||
local util = require("substore.util")
|
||||
local node = require("substore.node")
|
||||
|
||||
local M = {}
|
||||
|
||||
-- 解析 Surge 风格行:Name = proto, server, port, k=v, ...
|
||||
local function parse_surge_line(name, rest)
|
||||
local parts = {}
|
||||
for part in rest:gmatch("[^,]+") do
|
||||
parts[#parts + 1] = util.trim(part)
|
||||
end
|
||||
if #parts < 3 then return nil end
|
||||
|
||||
local proto = parts[1]:lower()
|
||||
if proto == "ss" then proto = "shadowsocks" end
|
||||
local server = parts[2]
|
||||
local port = tonumber(parts[3])
|
||||
|
||||
local kv = {}
|
||||
for i = 4, #parts do
|
||||
local k, v = parts[i]:match("^([%w%-]+)%s*=%s*(.*)$")
|
||||
if k then kv[k] = util.trim(v) end
|
||||
end
|
||||
|
||||
local data = {
|
||||
proto = proto, name = util.trim(name), server = server, port = port,
|
||||
}
|
||||
if proto == "shadowsocks" then
|
||||
data.method = kv["encrypt-method"] or kv.cipher or kv.method
|
||||
data.password = kv.password
|
||||
elseif proto == "vmess" or proto == "vless" then
|
||||
data.uuid = kv.username or kv.uuid
|
||||
if kv.flow then data.flow = kv.flow end
|
||||
elseif proto == "trojan" then
|
||||
data.password = kv.password
|
||||
elseif proto == "hysteria2" or proto == "hysteria" then
|
||||
data.password = kv.password
|
||||
end
|
||||
|
||||
-- 传输/TLS 通用字段
|
||||
if kv.net or kv.network then data.net = kv.net or kv.network end
|
||||
if kv["ws"] == "true" then data.net = "ws" end
|
||||
if kv["ws-path"] then data.path = kv["ws-path"] end
|
||||
if kv["ws-headers"] and kv["ws-headers"]:match("^Host:") then
|
||||
data.host = kv["ws-headers"]:match("^Host:%s*(.*)$")
|
||||
end
|
||||
if kv.sni then data.sni = kv.sni end
|
||||
if kv.tls and kv.tls ~= "false" and kv.tls ~= "none" then data.security = "tls" end
|
||||
if kv["skip-cert-verify"] and kv["skip-cert-verify"] ~= "0" and kv["skip-cert-verify"] ~= "false" then
|
||||
data["skip-cert-verify"] = true
|
||||
end
|
||||
return node.normalize(data)
|
||||
end
|
||||
|
||||
-- 解析 QX server_local 行:proto=server:port, k=v, ..., tag=Name
|
||||
local function parse_qx_line(content)
|
||||
local proto, rest = content:match("^([%w_]+)%s*=%s*(.+)$")
|
||||
if not proto then return nil end
|
||||
local parts = {}
|
||||
for part in rest:gmatch("[^,]+") do
|
||||
parts[#parts + 1] = util.trim(part)
|
||||
end
|
||||
if #parts < 1 then return nil end
|
||||
|
||||
local hostport = parts[1]
|
||||
local host, port = util.split_hostport(hostport)
|
||||
local kv = {}
|
||||
for i = 2, #parts do
|
||||
local k, v = parts[i]:match("^([%w%-]+)%s*=%s*(.*)$")
|
||||
if k then kv[k] = v end
|
||||
end
|
||||
|
||||
local name = kv.tag or (host .. ":" .. tostring(port or ""))
|
||||
proto = proto:lower()
|
||||
if proto == "ss" then proto = "shadowsocks" end
|
||||
|
||||
local data = { proto = proto, name = name, server = host, port = tonumber(port) }
|
||||
if proto == "shadowsocks" then
|
||||
data.method = kv.method
|
||||
data.password = kv.password
|
||||
elseif proto == "vmess" or proto == "vless" then
|
||||
data.uuid = kv.password or kv.uuid
|
||||
elseif proto == "trojan" then
|
||||
data.password = kv.password
|
||||
end
|
||||
if kv["obfs"] == "ws" then data.net = "ws" end
|
||||
if kv["obfs-uri"] then data.path = kv["obfs-uri"] end
|
||||
if kv["obfs-host"] then data.host = kv["obfs-host"] end
|
||||
if kv["tls-host"] then data.sni = kv["tls-host"] end
|
||||
if kv["tls-verification"] == "true" or kv["over-tls"] == "true" then data.security = "tls" end
|
||||
return node.normalize(data)
|
||||
end
|
||||
|
||||
-- 解析客户端配置,返回节点列表(自动识别 Surge/Loon 与 QX 风格)
|
||||
function M.parse(content)
|
||||
if not content or content == "" then return {} end
|
||||
local nodes = {}
|
||||
local section = ""
|
||||
for line in content:gmatch("[^\r\n]+") do
|
||||
local t = util.trim(line)
|
||||
if t == "" or t:sub(1, 1) == "#" or t:sub(1, 1) == ";" then
|
||||
-- 跳过空行/注释
|
||||
elseif t:match("^%[") then
|
||||
section = t:match("^%[([^%]]+)%]") or ""
|
||||
section = section:lower()
|
||||
elseif section == "proxy" then
|
||||
local name, rest = t:match("^([^=]-)%s*=%s*(.+)$")
|
||||
if name and rest then
|
||||
local n = parse_surge_line(name, rest)
|
||||
if n then nodes[#nodes + 1] = n end
|
||||
end
|
||||
elseif section == "server_local" then
|
||||
local n = parse_qx_line(t)
|
||||
if n then nodes[#nodes + 1] = n end
|
||||
end
|
||||
end
|
||||
return nodes
|
||||
end
|
||||
|
||||
-- 判断内容是否为客户端配置文件
|
||||
function M.is_config(content)
|
||||
content = content or ""
|
||||
return content:find("%[Proxy%]") ~= nil
|
||||
or content:find("%[proxy%]") ~= nil
|
||||
or content:find("%[server_local%]") ~= nil
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -0,0 +1,187 @@
|
||||
-- parser_yaml.lua — YAML 解析器:Clash/Sing-box → 统一节点模型(纯 Lua)
|
||||
-- luci-app-substore
|
||||
|
||||
local util = require("substore.util")
|
||||
local node = require("substore.node")
|
||||
|
||||
local M = {}
|
||||
|
||||
local proto_map = {
|
||||
vmess = "vmess",
|
||||
vless = "vless",
|
||||
trojan = "trojan",
|
||||
ss = "shadowsocks",
|
||||
shadowsocks = "shadowsocks",
|
||||
ssr = "ssr",
|
||||
http = "http",
|
||||
socks5 = "socks5",
|
||||
}
|
||||
|
||||
local function split_lines(content)
|
||||
local out = {}
|
||||
for line in content:gmatch("[^\r\n]+") do
|
||||
out[#out + 1] = line
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
local function trim_quotes(v)
|
||||
return v:gsub('^["\'](.*)["\']$', '%1')
|
||||
end
|
||||
|
||||
local function parse_yaml_section(content, section_key)
|
||||
local lines = split_lines(content)
|
||||
local nodes = {}
|
||||
local current = nil
|
||||
local in_section = false
|
||||
local section_indent = 0
|
||||
local list_indent = nil
|
||||
|
||||
for _, line in ipairs(lines) do
|
||||
local trimmed = util.trim(line)
|
||||
if trimmed == "" or trimmed:sub(1, 1) == "#" then
|
||||
-- skip empty and comments
|
||||
else
|
||||
local indent = line:match("^%s*") and #line:match("^%s*") or 0
|
||||
-- section start
|
||||
if trimmed:match("^" .. section_key .. ":%s*$") then
|
||||
in_section = true
|
||||
section_indent = indent
|
||||
current = nil
|
||||
list_indent = nil
|
||||
elseif in_section then
|
||||
if indent <= section_indent then
|
||||
in_section = false
|
||||
if current then
|
||||
nodes[#nodes + 1] = current
|
||||
current = nil
|
||||
end
|
||||
else
|
||||
local list_match = line:match("^%s*%-%s*(.*)$")
|
||||
if list_match then
|
||||
if current then
|
||||
nodes[#nodes + 1] = current
|
||||
end
|
||||
current = {}
|
||||
-- inline key:value on same line as -
|
||||
local k, v = list_match:match("^([^:]+):%s*(.+)$")
|
||||
if k then
|
||||
current[util.trim(k)] = trim_quotes(util.trim(v))
|
||||
end
|
||||
list_indent = indent
|
||||
elseif current and indent > (list_indent or section_indent) then
|
||||
local k, v = trimmed:match("^([^:]+):%s*(.+)$")
|
||||
if k then
|
||||
k = util.trim(k)
|
||||
v = util.trim(v)
|
||||
v = trim_quotes(v)
|
||||
-- ignore nested structures with empty value
|
||||
if v ~= "" then
|
||||
current[k] = v
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if current then
|
||||
nodes[#nodes + 1] = current
|
||||
end
|
||||
|
||||
return nodes
|
||||
end
|
||||
|
||||
local function normalize_clash_node(raw)
|
||||
if not raw.server or not raw.port then
|
||||
return nil
|
||||
end
|
||||
local proto = proto_map[raw.type or raw.proto] or "vmess"
|
||||
local node_data = {
|
||||
proto = proto,
|
||||
name = raw.name or raw.Name or (raw.server .. ":" .. tostring(raw.port or "")),
|
||||
server = raw.server,
|
||||
port = tonumber(raw.port),
|
||||
uuid = raw.uuid or raw.id,
|
||||
password = raw.password,
|
||||
method = raw.cipher or raw.method,
|
||||
net = raw.network or raw.net,
|
||||
security = raw.tls or raw.security,
|
||||
sni = raw.sni or raw.servername,
|
||||
alterId = tonumber(raw.alterId),
|
||||
}
|
||||
return node.normalize(node_data)
|
||||
end
|
||||
|
||||
function M.parse_clash_yaml(content)
|
||||
local raw_nodes = parse_yaml_section(content, "proxies")
|
||||
-- also try outbounds for compatibility
|
||||
if #raw_nodes == 0 then
|
||||
raw_nodes = parse_yaml_section(content, "outbounds")
|
||||
end
|
||||
local result = {}
|
||||
for _, raw in ipairs(raw_nodes) do
|
||||
local n = normalize_clash_node(raw)
|
||||
if n then
|
||||
result[#result + 1] = n
|
||||
end
|
||||
end
|
||||
return result
|
||||
end
|
||||
|
||||
local function normalize_singbox_node(raw)
|
||||
if not raw.server or not raw.server_port then
|
||||
return nil
|
||||
end
|
||||
local proto = raw.type
|
||||
-- map proto names
|
||||
if proto == "shadowsocks" then proto = "shadowsocks" end
|
||||
if proto == "ss" then proto = "shadowsocks" end
|
||||
local node_data = {
|
||||
proto = proto,
|
||||
name = raw.tag or (raw.server .. ":" .. tostring(raw.server_port or "")),
|
||||
server = raw.server,
|
||||
port = tonumber(raw.server_port),
|
||||
uuid = raw.uuid,
|
||||
password = raw.password,
|
||||
method = raw.method,
|
||||
security = raw.security,
|
||||
sni = raw.servername,
|
||||
net = raw.network,
|
||||
}
|
||||
return node.normalize(node_data)
|
||||
end
|
||||
|
||||
function M.parse_singbox_yaml(content)
|
||||
local raw_nodes = parse_yaml_section(content, "outbounds")
|
||||
local result = {}
|
||||
for _, raw in ipairs(raw_nodes) do
|
||||
local n = normalize_singbox_node(raw)
|
||||
if n then
|
||||
result[#result + 1] = n
|
||||
end
|
||||
end
|
||||
return result
|
||||
end
|
||||
|
||||
-- 通用解析:根据 section 自动选择
|
||||
function M.parse(content)
|
||||
if not content or content == "" then
|
||||
return {}
|
||||
end
|
||||
if content:find("proxies:", 1, true) then
|
||||
return M.parse_clash_yaml(content)
|
||||
elseif content:find("outbounds:", 1, true) then
|
||||
-- 尝试 sing-box 格式
|
||||
local nodes = M.parse_singbox_yaml(content)
|
||||
if #nodes > 0 then
|
||||
return nodes
|
||||
end
|
||||
-- fallback to clash parser
|
||||
return M.parse_clash_yaml(content)
|
||||
end
|
||||
return {}
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -0,0 +1,153 @@
|
||||
-- probe.lua — 节点网络探测(Ping / TCPing / URL 测试)(纯 Lua,无 luci.* 依赖)
|
||||
-- luci-app-substore
|
||||
-- 依赖 busybox 内置工具:ping、nc、wget、cut(OpenWrt / ImmortalWrt 均内置)
|
||||
|
||||
local util = require("substore.util")
|
||||
|
||||
local M = {}
|
||||
|
||||
M.TIMEOUT = 2 -- 每个目标的超时(秒)
|
||||
|
||||
-- shell 引号(%q 双引号转义),配合 safe_host 校验,双重防命令注入
|
||||
local function q(s) return string.format("%q", s) end
|
||||
|
||||
-- 校验目标主机名/IP:仅允许 [A-Za-z0-9._-] 与冒号(IPv6),杜绝命令注入
|
||||
local function safe_host(host)
|
||||
return type(host) == "string" and host ~= "" and host:match("^[%w%.%-%:]+$") ~= nil
|
||||
end
|
||||
|
||||
local function safe_port(port)
|
||||
port = tonumber(port)
|
||||
if port and port >= 1 and port <= 65535 then return port end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- 解析 ping 输出的 time=xx.x ms
|
||||
function M.parse_ping(out)
|
||||
out = out or ""
|
||||
local t = out:match("time=([%d%.]+)")
|
||||
return t and tonumber(t) or nil
|
||||
end
|
||||
|
||||
-- 解析 shell 包装输出的 "起始uptime 结束uptime"(单位秒),返回毫秒延迟
|
||||
function M.parse_delta(out)
|
||||
out = util.trim(out or "")
|
||||
local a, b = out:match("^(%d+%.?%d*)%s+(%d+%.?%d*)$")
|
||||
if a and b then
|
||||
local d = (tonumber(b) - tonumber(a)) * 1000
|
||||
if d >= 0 then return d end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- URL 测试命令与解析(优先 curl 输出 time_total,回退 wget + uptime 差值)
|
||||
local url_tool
|
||||
local function detect_url_tool()
|
||||
if url_tool == nil then
|
||||
local f = io.popen("command -v curl >/dev/null 2>&1 && echo curl || echo wget")
|
||||
url_tool = util.trim(f and f:read("*a") or "wget")
|
||||
if f then f:close() end
|
||||
end
|
||||
return url_tool
|
||||
end
|
||||
|
||||
local function build_url_job(url)
|
||||
if detect_url_tool() == "curl" then
|
||||
local cmd = "curl -s -o /dev/null --connect-timeout " .. M.TIMEOUT .. " --max-time " .. M.TIMEOUT
|
||||
.. " -w '%{http_code} %{time_total}' " .. q(url) .. " 2>/dev/null"
|
||||
return cmd, function(out)
|
||||
-- http_code=000 表示连接失败(拒绝/超时),其余状态码均说明对端可用 HTTP 通信
|
||||
local code, t = util.trim(out or ""):match("^(%d+)%s+([%d%.]+)$")
|
||||
t = tonumber(t)
|
||||
if code and code ~= "000" and t and t > 0 then return t * 1000 end
|
||||
return nil
|
||||
end
|
||||
end
|
||||
local cmd = "a=$(cut -d' ' -f1 /proc/uptime); if wget -q -T " .. M.TIMEOUT .. " -O /dev/null " .. q(url)
|
||||
.. " 2>/dev/null; then b=$(cut -d' ' -f1 /proc/uptime); echo \"$a $b\"; fi"
|
||||
return cmd, M.parse_delta
|
||||
end
|
||||
|
||||
-- 单节点探测(串行,便于单测)。返回延迟 ms(number)或 nil(失败/超时)
|
||||
function M.ping(server)
|
||||
server = util.trim(server or "")
|
||||
if not safe_host(server) then return nil end
|
||||
local f = io.popen("ping -c 1 -W " .. M.TIMEOUT .. " " .. q(server) .. " 2>/dev/null")
|
||||
local out = f and f:read("*a") or ""
|
||||
if f then f:close() end
|
||||
return M.parse_ping(out)
|
||||
end
|
||||
|
||||
function M.tcping(server, port)
|
||||
server = util.trim(server or "")
|
||||
port = safe_port(port)
|
||||
if not safe_host(server) or not port then return nil end
|
||||
local cmd = "a=$(cut -d' ' -f1 /proc/uptime); if nc -z -w " .. M.TIMEOUT .. " " .. q(server) .. " " .. port
|
||||
.. " >/dev/null 2>&1; then b=$(cut -d' ' -f1 /proc/uptime); echo \"$a $b\"; fi"
|
||||
local f = io.popen(cmd)
|
||||
local out = f and f:read("*a") or ""
|
||||
if f then f:close() end
|
||||
return M.parse_delta(out)
|
||||
end
|
||||
|
||||
function M.url_test(server, port)
|
||||
server = util.trim(server or "")
|
||||
port = safe_port(port)
|
||||
if not safe_host(server) or not port then return nil end
|
||||
local url = "http://" .. server .. ":" .. port .. "/"
|
||||
local cmd, parse = build_url_job(url)
|
||||
local f = io.popen(cmd)
|
||||
local out = f and f:read("*a") or ""
|
||||
if f then f:close() end
|
||||
return parse(out)
|
||||
end
|
||||
|
||||
-- 为单个节点构造探测任务:返回 cmd, parse 或 nil
|
||||
local function build_job(mode, server, port)
|
||||
server = util.trim(server or "")
|
||||
if not safe_host(server) then return nil end
|
||||
if mode == "ping" then
|
||||
return "ping -c 1 -W " .. M.TIMEOUT .. " " .. q(server) .. " 2>/dev/null", M.parse_ping
|
||||
elseif mode == "tcping" then
|
||||
port = safe_port(port)
|
||||
if not port then return nil end
|
||||
local cmd = "a=$(cut -d' ' -f1 /proc/uptime); if nc -z -w " .. M.TIMEOUT .. " " .. q(server) .. " " .. port
|
||||
.. " >/dev/null 2>&1; then b=$(cut -d' ' -f1 /proc/uptime); echo \"$a $b\"; fi"
|
||||
return cmd, M.parse_delta
|
||||
elseif mode == "url" then
|
||||
port = safe_port(port)
|
||||
if not port then return nil end
|
||||
return build_url_job("http://" .. server .. ":" .. port .. "/")
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- 批量探测:mode = "ping" | "tcping" | "url"
|
||||
-- 并行启动所有子进程再统一读取,总耗时≈单节点超时,而非节点数×超时。
|
||||
-- 返回结果数组 { name=, server=, port=, latency=number|nil }
|
||||
function M.probe(nodes, mode)
|
||||
local jobs = {}
|
||||
for _, n in ipairs(nodes or {}) do
|
||||
local cmd, parse = build_job(mode, n.server, n.port)
|
||||
local f = cmd and io.popen(cmd) or nil
|
||||
jobs[#jobs + 1] = {
|
||||
f = f, parse = parse,
|
||||
name = n.name or "", server = n.server or "", port = n.port or "",
|
||||
}
|
||||
end
|
||||
local out = {}
|
||||
for _, j in ipairs(jobs) do
|
||||
local latency
|
||||
if j.f then
|
||||
local raw = j.f:read("*a") or ""
|
||||
j.f:close()
|
||||
latency = j.parse and j.parse(raw) or nil
|
||||
end
|
||||
out[#out + 1] = {
|
||||
name = j.name, server = j.server, port = j.port, latency = latency,
|
||||
}
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -0,0 +1,381 @@
|
||||
-- util.lua — 通用工具(纯 Lua 5.1,无外部依赖)
|
||||
-- luci-app-substore
|
||||
|
||||
local M = {}
|
||||
|
||||
local B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
|
||||
|
||||
function M.trim(s)
|
||||
return (s:gsub("^%s+", ""):gsub("%s+$", ""))
|
||||
end
|
||||
|
||||
-- 安全 require:模块不存在时返回 nil 而不报错
|
||||
function M.try_require(name)
|
||||
local ok, mod = pcall(require, name)
|
||||
if ok and mod then return mod end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- ---------- Base64 ----------
|
||||
function M.base64_encode(s)
|
||||
if type(s) ~= "string" then return "" end
|
||||
local out = {}
|
||||
for i = 1, #s, 3 do
|
||||
local b1 = s:byte(i)
|
||||
local b2 = s:byte(i + 1)
|
||||
local b3 = s:byte(i + 2)
|
||||
local n = b1 * 65536 + (b2 or 0) * 256 + (b3 or 0)
|
||||
out[#out + 1] = B64:sub(math.floor(n / 262144) % 64 + 1, math.floor(n / 262144) % 64 + 1)
|
||||
out[#out + 1] = B64:sub(math.floor(n / 4096) % 64 + 1, math.floor(n / 4096) % 64 + 1)
|
||||
out[#out + 1] = b2 and B64:sub(math.floor(n / 64) % 64 + 1, math.floor(n / 64) % 64 + 1) or "="
|
||||
out[#out + 1] = b3 and B64:sub(n % 64 + 1, n % 64 + 1) or "="
|
||||
end
|
||||
return table.concat(out)
|
||||
end
|
||||
|
||||
function M.base64_decode(s)
|
||||
if type(s) ~= "string" then return "" end
|
||||
s = s:gsub("[^%w%+/=]", "")
|
||||
local out = {}
|
||||
local n, bits = 0, 0
|
||||
for i = 1, #s do
|
||||
local c = s:sub(i, i)
|
||||
if c == "=" then break end
|
||||
local v = B64:find(c, 1, true)
|
||||
if v then
|
||||
n = n * 64 + (v - 1)
|
||||
bits = bits + 6
|
||||
while bits >= 8 do
|
||||
bits = bits - 8
|
||||
n = math.floor(n)
|
||||
local byte = math.floor(n / (2 ^ bits)) % 256
|
||||
out[#out + 1] = string.char(byte)
|
||||
n = n % (2 ^ bits)
|
||||
end
|
||||
end
|
||||
end
|
||||
return table.concat(out)
|
||||
end
|
||||
|
||||
-- URL-safe Base64(RFC 4648 base64url):- _ 代替 + /,无 padding
|
||||
function M.base64_url_encode(s)
|
||||
s = s or ""
|
||||
return (M.base64_encode(s):gsub("+", "-"):gsub("/", "_"):gsub("=+$", ""))
|
||||
end
|
||||
|
||||
function M.base64_url_decode(s)
|
||||
if type(s) ~= "string" then return "" end
|
||||
s = s:gsub("-", "+"):gsub("_", "/")
|
||||
return M.base64_decode(s)
|
||||
end
|
||||
|
||||
-- 生成随机十六进制 token(用于下载链接访问控制)
|
||||
function M.rnd_hex(len)
|
||||
len = len or 16
|
||||
local hex = "0123456789abcdef"
|
||||
local out = {}
|
||||
math.randomseed(os.time() + (os.clock() * 1000000 % 1000000) + math.random(0, 65535))
|
||||
for _ = 1, len do
|
||||
local idx = math.random(1, 16)
|
||||
out[#out + 1] = hex:sub(idx, idx)
|
||||
end
|
||||
return table.concat(out)
|
||||
end
|
||||
|
||||
-- ---------- URL ----------
|
||||
function M.url_decode(s)
|
||||
if type(s) ~= "string" then return "" end
|
||||
return s:gsub("%%(%x%x)", function(h) return string.char(tonumber(h, 16)) end)
|
||||
end
|
||||
|
||||
-- 从 query string 提取字段(接受 URL 片段,如 "…?a=1&b=2#frag")
|
||||
function M.url_extract(s, field)
|
||||
if type(s) ~= "string" then return nil end
|
||||
local q = s:match("[^#?]*%?([^#]*)")
|
||||
if not q then return nil end
|
||||
for k, v in q:gmatch("([^&=]+)=([^&]*)") do
|
||||
if k == field then return M.url_decode(v) end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- 拆分 host:port,支持 [ipv6]:port
|
||||
function M.split_hostport(s)
|
||||
if type(s) ~= "string" then return nil, nil end
|
||||
s = M.trim(s)
|
||||
-- 去掉可能尾随的路径(如 "host:port/path")
|
||||
s = s:match("^([^/]*)") or s
|
||||
if s == "" then return nil, nil end
|
||||
if s:sub(1, 1) == "[" then
|
||||
local close = s:find("]", 1, true)
|
||||
if not close then return nil, nil end
|
||||
local host = s:sub(2, close - 1)
|
||||
local port = s:sub(close + 1):match("^:(%d+)$")
|
||||
return host, port
|
||||
else
|
||||
local host, port = s:match("^([^:]+):(%d+)$")
|
||||
if host then return host, port end
|
||||
return s, nil
|
||||
end
|
||||
end
|
||||
|
||||
-- ---------- JSON ----------
|
||||
local function utf8_char(cp)
|
||||
if cp < 0x80 then
|
||||
return string.char(cp)
|
||||
elseif cp < 0x800 then
|
||||
return string.char(0xC0 + math.floor(cp / 0x40), 0x80 + cp % 0x40)
|
||||
elseif cp < 0x10000 then
|
||||
return string.char(0xE0 + math.floor(cp / 0x1000),
|
||||
0x80 + math.floor(cp / 0x40) % 0x40, 0x80 + cp % 0x40)
|
||||
else
|
||||
return string.char(0xF0 + math.floor(cp / 0x40000),
|
||||
0x80 + math.floor(cp / 0x1000) % 0x40,
|
||||
0x80 + math.floor(cp / 0x40) % 0x40, 0x80 + cp % 0x40)
|
||||
end
|
||||
end
|
||||
|
||||
local function is_array(t)
|
||||
local i = 0
|
||||
for k in pairs(t) do
|
||||
i = i + 1
|
||||
if t[i] == nil then return false end
|
||||
end
|
||||
return i == #t
|
||||
end
|
||||
|
||||
-- JSON null 占位符:仅在数组中用于保留位置(对象中的 null 仍解码为 nil)
|
||||
local JSON_NULL = {}
|
||||
|
||||
function M.json_encode(v)
|
||||
local function enc(v)
|
||||
local t = type(v)
|
||||
if v == nil then
|
||||
return "null"
|
||||
elseif t == "string" then
|
||||
return '"' .. v:gsub('[%z\1-\31\\"]', function(c)
|
||||
if c == '"' then return '\\"'
|
||||
elseif c == "\\" then return "\\\\"
|
||||
elseif c == "\n" then return "\\n"
|
||||
elseif c == "\r" then return "\\r"
|
||||
elseif c == "\t" then return "\\t"
|
||||
elseif c == "\b" then return "\\b"
|
||||
elseif c == "\f" then return "\\f"
|
||||
else return string.format("\\u%04x", c:byte()) end
|
||||
end) .. '"'
|
||||
elseif t == "number" then
|
||||
if v ~= v then return "null" end
|
||||
return string.format("%.14g", v)
|
||||
elseif t == "boolean" then
|
||||
return v and "true" or "false"
|
||||
elseif t == "table" then
|
||||
if is_array(v) then
|
||||
local a = {}
|
||||
for i = 1, #v do a[#a + 1] = enc(v[i]) end
|
||||
return "[" .. table.concat(a, ",") .. "]"
|
||||
else
|
||||
local a = {}
|
||||
for k, val in pairs(v) do
|
||||
a[#a + 1] = enc(tostring(k)) .. ":" .. enc(val)
|
||||
end
|
||||
return "{" .. table.concat(a, ",") .. "}"
|
||||
end
|
||||
end
|
||||
return "null"
|
||||
end
|
||||
return enc(v)
|
||||
end
|
||||
|
||||
function M.json_decode(s)
|
||||
if type(s) ~= "string" then return nil, "not a string" end
|
||||
local i = 1
|
||||
local len = #s
|
||||
|
||||
local function skip_ws()
|
||||
while true do
|
||||
local c = s:sub(i, i)
|
||||
if c == " " or c == "\t" or c == "\n" or c == "\r" then i = i + 1 else break end
|
||||
end
|
||||
end
|
||||
|
||||
local function parse()
|
||||
skip_ws()
|
||||
if i > len then return nil, "unexpected end" end
|
||||
local c = s:sub(i, i)
|
||||
|
||||
if c == "{" then
|
||||
i = i + 1
|
||||
local obj = {}
|
||||
skip_ws()
|
||||
if s:sub(i, i) == "}" then i = i + 1 return obj end
|
||||
while true do
|
||||
skip_ws()
|
||||
local k, ke = parse()
|
||||
if not k or type(k) ~= "string" then return nil, "expected string key" end
|
||||
skip_ws()
|
||||
if s:sub(i, i) ~= ":" then return nil, "expected ':'" end
|
||||
i = i + 1
|
||||
local val = parse()
|
||||
obj[k] = val
|
||||
skip_ws()
|
||||
local cc = s:sub(i, i)
|
||||
if cc == "," then
|
||||
i = i + 1
|
||||
elseif cc == "}" then
|
||||
i = i + 1
|
||||
return obj
|
||||
else
|
||||
return nil, "expected ',' or '}'"
|
||||
end
|
||||
end
|
||||
|
||||
elseif c == "[" then
|
||||
i = i + 1
|
||||
local arr = {}
|
||||
skip_ws()
|
||||
if s:sub(i, i) == "]" then i = i + 1 return arr end
|
||||
while true do
|
||||
local val = parse()
|
||||
if val == nil then val = JSON_NULL end
|
||||
arr[#arr + 1] = val
|
||||
skip_ws()
|
||||
local cc = s:sub(i, i)
|
||||
if cc == "," then
|
||||
i = i + 1
|
||||
elseif cc == "]" then
|
||||
i = i + 1
|
||||
return arr
|
||||
else
|
||||
return nil, "expected ',' or ']'"
|
||||
end
|
||||
end
|
||||
|
||||
elseif c == '"' then
|
||||
i = i + 1
|
||||
local out = {}
|
||||
while true do
|
||||
local ch = s:sub(i, i)
|
||||
if ch == "" then return nil, "unterminated string" end
|
||||
if ch == '"' then i = i + 1 return table.concat(out) end
|
||||
if ch == "\\" then
|
||||
i = i + 1
|
||||
local e = s:sub(i, i)
|
||||
i = i + 1
|
||||
local map = { ['"'] = '"', ["\\"] = "\\", ["/"] = "/",
|
||||
b = "\b", f = "\f", n = "\n", r = "\r", t = "\t" }
|
||||
if e == "u" then
|
||||
local hex = s:sub(i, i + 3)
|
||||
i = i + 4
|
||||
local cp = tonumber(hex, 16)
|
||||
if not cp then return nil, "bad unicode" end
|
||||
if cp >= 0xD800 and cp <= 0xDBFF then
|
||||
if s:sub(i, i) == "\\" and s:sub(i + 1, i + 1) == "u" then
|
||||
local hex2 = s:sub(i + 2, i + 5)
|
||||
local cp2 = tonumber(hex2, 16)
|
||||
if cp2 and cp2 >= 0xDC00 and cp2 <= 0xDFFF then
|
||||
i = i + 6
|
||||
cp = 0x10000 + (cp - 0xD800) * 0x400 + (cp2 - 0xDC00)
|
||||
end
|
||||
end
|
||||
end
|
||||
out[#out + 1] = utf8_char(cp)
|
||||
else
|
||||
out[#out + 1] = map[e] or e
|
||||
end
|
||||
else
|
||||
out[#out + 1] = ch
|
||||
i = i + 1
|
||||
end
|
||||
end
|
||||
|
||||
elseif c == "t" and s:sub(i, i + 3) == "true" then
|
||||
i = i + 4
|
||||
return true
|
||||
elseif c == "f" and s:sub(i, i + 4) == "false" then
|
||||
i = i + 5
|
||||
return false
|
||||
elseif c == "n" and s:sub(i, i + 3) == "null" then
|
||||
i = i + 4
|
||||
return nil
|
||||
elseif c:match("[%-%d]") then
|
||||
local num = s:match("^-?%d+%.?%d*[eE]?[%+%-]?%d*", i)
|
||||
if not num or num == "" then return nil, "bad number" end
|
||||
i = i + #num
|
||||
local n = tonumber(num)
|
||||
if not n then return nil, "bad number value" end
|
||||
return n
|
||||
else
|
||||
return nil, "unexpected char " .. c
|
||||
end
|
||||
end
|
||||
|
||||
local v = parse()
|
||||
return v
|
||||
end
|
||||
|
||||
-- ---------- 文件 ----------
|
||||
function M.read_file(path)
|
||||
local f, e = io.open(path, "rb")
|
||||
if not f then return nil, e end
|
||||
local data = f:read("*a")
|
||||
f:close()
|
||||
return data
|
||||
end
|
||||
|
||||
function M.file_size(path)
|
||||
local f = io.open(path, "rb")
|
||||
if not f then return 0 end
|
||||
local data = f:read("*a")
|
||||
f:close()
|
||||
return #data
|
||||
end
|
||||
|
||||
-- 原子写:先写临时文件再重命名,避免写一半损坏
|
||||
function M.atomic_write(path, content)
|
||||
local tmp = path .. ".tmp"
|
||||
local f, e = io.open(tmp, "wb")
|
||||
if not f then return false, e end
|
||||
f:write(content)
|
||||
f:close()
|
||||
local ok, e2 = os.rename(tmp, path)
|
||||
if not ok then
|
||||
os.remove(tmp)
|
||||
return false, e2
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
function M.ensure_dir(path)
|
||||
os.execute("mkdir -p " .. string.format("%q", path))
|
||||
end
|
||||
|
||||
-- ---------- 人性化格式 ----------
|
||||
-- 字节数 → 可读字符串(B/K/M/G/T),如 20G、512M
|
||||
function M.human_bytes(n)
|
||||
n = tonumber(n) or 0
|
||||
n = math.floor(n + 0.5)
|
||||
if n <= 0 then return "0B" end
|
||||
local units = { "B", "K", "M", "G", "T" }
|
||||
local i, v = 1, n
|
||||
while v >= 1024 and i < #units do
|
||||
v = v / 1024
|
||||
i = i + 1
|
||||
end
|
||||
local s = string.format("%.1f", v):gsub("%.0$", "")
|
||||
return s .. units[i]
|
||||
end
|
||||
|
||||
-- 秒数 → 可读时长(天/小时/分钟),如 10天、8小时
|
||||
function M.human_duration(secs)
|
||||
secs = tonumber(secs) or 0
|
||||
if secs <= 0 then return "已过期" end
|
||||
local d = secs / 86400
|
||||
if d >= 1 then return string.format("%d天", math.floor(d)) end
|
||||
local h = secs / 3600
|
||||
if h >= 1 then return string.format("%d小时", math.floor(h)) end
|
||||
local m = secs / 60
|
||||
if m >= 1 then return string.format("%d分钟", math.floor(m)) end
|
||||
return "不足1分钟"
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -0,0 +1,138 @@
|
||||
/* nodeform.js — 节点动态字段表单(local_form.htm / node_edit.htm 共用)
|
||||
* 依赖页面在引入本文件前定义:
|
||||
* var FIELD_LABELS = { "name": "...", "group": "...", ... }; // 字段标签(模板用 <%:...%> 渲染)
|
||||
* var NODEFORM_I18N = { node: "...", defaultOpt: "..." }; // 界面文本(同上)
|
||||
* 提供:SUBSTORE_PROTOS / PROTO_FIELDS / renderFields / collectNodes / addNodeRow
|
||||
*/
|
||||
|
||||
var SUBSTORE_PROTOS = ["vmess","vless","trojan","shadowsocks","ssr","hysteria2","tuic","wireguard"];
|
||||
|
||||
var PROTO_FIELDS = {
|
||||
ssr: ["server","port","password","cipher","protocol","obfs","obfs-param","protocol-param","udp"],
|
||||
vmess: ["server","port","uuid","alterId","cipher","net","headerType","path","host","sni","tls","udp","skip-cert-verify"],
|
||||
vless: ["server","port","uuid","security","flow","net","headerType","path","host","sni","udp","skip-cert-verify"],
|
||||
trojan: ["server","port","password","sni","net","headerType","path","host","udp","skip-cert-verify"],
|
||||
shadowsocks: ["server","port","password","method","headerType","udp"],
|
||||
hysteria2: ["server","port","password","sni","obfs","obfs-password","skip-cert-verify"],
|
||||
tuic: ["server","port","uuid","password","sni","udp","skip-cert-verify"],
|
||||
wireguard: ["server","port","private-key","peer-public-key","udp"]
|
||||
};
|
||||
|
||||
// 枚举字段用下拉框;键为 "协议.字段" 优先,退化为通用 "字段"
|
||||
// 选项集合以模型/输出模块实际支持的值为准(见 output_singbox.build_transport / output_uri)
|
||||
var SELECT_FIELDS = {
|
||||
"net": ["tcp","ws","h2","grpc"],
|
||||
"headerType": ["none","http"],
|
||||
"cipher": ["auto","aes-128-gcm","chacha20-poly1305","none","zero"],
|
||||
"method": ["aes-128-gcm","aes-256-gcm","chacha20-ietf-poly1305","xchacha20-ietf-poly1305","aes-128-cfb","aes-256-cfb","rc4-md5","chacha20-ietf","2022-blake3-aes-128-gcm","2022-blake3-aes-256-gcm","2022-blake3-chacha20-poly1305"],
|
||||
"security": ["none","tls","reality"],
|
||||
"flow": ["","xtls-rprx-vision"],
|
||||
"tls": ["","tls"],
|
||||
"hysteria2.obfs": ["","salamander"]
|
||||
};
|
||||
|
||||
// 布尔字段用下拉框:默认(不提交)/ true / false
|
||||
var BOOL_FIELDS = { "udp": true, "skip-cert-verify": true };
|
||||
|
||||
function nodeformLabel(k) {
|
||||
return (typeof FIELD_LABELS === "object" && FIELD_LABELS && FIELD_LABELS[k]) || k;
|
||||
}
|
||||
|
||||
function nodeformT(k) {
|
||||
return (typeof NODEFORM_I18N === "object" && NODEFORM_I18N && NODEFORM_I18N[k]) || k;
|
||||
}
|
||||
|
||||
function fieldRow(k, proto) {
|
||||
var label = nodeformLabel(k);
|
||||
var input;
|
||||
var opts = SELECT_FIELDS[proto + "." + k] || SELECT_FIELDS[k];
|
||||
if (opts) {
|
||||
input = '<select data-k="' + k + '">';
|
||||
for (var i = 0; i < opts.length; i++) {
|
||||
var v = opts[i];
|
||||
input += '<option value="' + v + '">' + (v === "" ? nodeformT("defaultOpt") : v) + '</option>';
|
||||
}
|
||||
input += '</select>';
|
||||
} else if (BOOL_FIELDS[k]) {
|
||||
input = '<select data-k="' + k + '">' +
|
||||
'<option value="">' + nodeformT("defaultOpt") + '</option>' +
|
||||
'<option value="true">true</option>' +
|
||||
'<option value="false">false</option>' +
|
||||
'</select>';
|
||||
} else {
|
||||
input = '<input type="text" data-k="' + k + '" style="width:50%"/>';
|
||||
}
|
||||
return '<label style="display:inline-block;min-width:10em">' + label + '</label>' + input + '<br/>';
|
||||
}
|
||||
|
||||
function nodeTemplate(idx) {
|
||||
var opts = "";
|
||||
for (var i = 0; i < SUBSTORE_PROTOS.length; i++) {
|
||||
opts += '<option value="' + SUBSTORE_PROTOS[i] + '">' + SUBSTORE_PROTOS[i] + '</option>';
|
||||
}
|
||||
return '<div class="node_row" style="border:1px solid #ddd;padding:0.5em;margin:0.5em 0">' +
|
||||
'<div style="margin-bottom:0.25em"><strong>' + nodeformT("node") + ' ' + (idx + 1) + '</strong> ' +
|
||||
'<button type="button" class="cbi-button cbi-button-reset node_remove" onclick="this.parentNode.parentNode.remove()">×</button></div>' +
|
||||
'<label style="display:inline-block;min-width:10em">' + nodeformLabel("name") + '</label>' +
|
||||
'<input type="text" data-k="name" style="width:50%"/><br/>' +
|
||||
'<label style="display:inline-block;min-width:10em">' + nodeformLabel("group") + '</label>' +
|
||||
'<input type="text" data-k="group" style="width:50%"/><br/>' +
|
||||
'<label style="display:inline-block;min-width:10em">' + nodeformLabel("protocol") + '</label>' +
|
||||
'<select data-k="type" onchange="renderFields(this)">' + opts + '</select><br/>' +
|
||||
'<div class="fields"></div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function renderFields(sel, data) {
|
||||
var row = sel.parentNode;
|
||||
while (row && row.className.indexOf("node_row") < 0) row = row.parentNode;
|
||||
if (!row) return;
|
||||
var fieldsDiv = row.querySelector(".fields");
|
||||
var proto = sel.value;
|
||||
var keys = PROTO_FIELDS[proto] || [];
|
||||
var html = "";
|
||||
for (var i = 0; i < keys.length; i++) html += fieldRow(keys[i], proto);
|
||||
fieldsDiv.innerHTML = html;
|
||||
if (data) {
|
||||
for (var j = 0; j < keys.length; j++) {
|
||||
var el = fieldsDiv.querySelector('[data-k="' + keys[j] + '"]');
|
||||
if (el && data[keys[j]] !== undefined && data[keys[j]] !== null) el.value = String(data[keys[j]]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function collectNodes() {
|
||||
var rows = document.querySelectorAll(".node_row");
|
||||
var out = [];
|
||||
for (var i = 0; i < rows.length; i++) {
|
||||
var obj = {};
|
||||
var els = rows[i].querySelectorAll("[data-k]");
|
||||
for (var j = 0; j < els.length; j++) {
|
||||
var k = els[j].getAttribute("data-k");
|
||||
var v = (els[j].value || "").replace(/^\s+|\s+$/g, "");
|
||||
if (v !== "") obj[k] = v;
|
||||
}
|
||||
if (obj.name || obj.server) out.push(obj);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function addNodeRow(data) {
|
||||
var list = document.getElementById("nodes_list");
|
||||
var div = document.createElement("div");
|
||||
div.innerHTML = nodeTemplate(list.children.length);
|
||||
var row = div.firstChild;
|
||||
list.appendChild(row);
|
||||
var sel = row.querySelector('select[data-k="type"]');
|
||||
if (data && (data.type || data.proto)) sel.value = data.type || data.proto;
|
||||
if (data && data.name) {
|
||||
var nameEl = row.querySelector('[data-k="name"]');
|
||||
if (nameEl) nameEl.value = data.name;
|
||||
}
|
||||
if (data && data.group) {
|
||||
var groupEl = row.querySelector('[data-k="group"]');
|
||||
if (groupEl) groupEl.value = data.group;
|
||||
}
|
||||
renderFields(sel, data);
|
||||
return row;
|
||||
}
|
||||
Reference in new issue
Block a user