🎉 Sync 2026-09-27 21:03:26

This commit is contained in:
github-actions[bot] committed 2026-09-27 21:03:26 +08:00
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
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
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;
}