mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-05 18:44:03 +08:00
🦄 Sync 2026-10-02 14:52:05
This commit is contained in:
1 parent
4c85793342
commit
0bfe84f1e8
133 files changed
+7983
-3225
No files matched your search
@@ -65,10 +65,38 @@ local function fv(http, key)
|
||||
return tostring(v)
|
||||
end
|
||||
|
||||
-- post_ok 失败的原因,供调用方回显(§18:失败必须让用户看见)。
|
||||
-- 每个请求是一个独立的 Lua 进程,不存在跨请求残留;同一进程内多次调用时
|
||||
-- post_ok 成功会把它清空,因此不会把上一次的失败带进这一次。
|
||||
local post_fail_msg = nil
|
||||
|
||||
-- POST 请求的 token 校验。
|
||||
--
|
||||
-- 上游 LuCI 渲染表单时,模板变量 `token` 就是 luci.dispatcher.context.authtoken
|
||||
-- (modules/luci-lua-runtime/luasrc/template.lua 的 viewns 元表:
|
||||
-- elseif key == "token" then return disp.context.authtoken),
|
||||
-- 所以「提交的 token == authtoken」正是框架自己那套判定,
|
||||
-- 不可能误拒合法表单 —— 合法表单提交的就是 authtoken。
|
||||
--
|
||||
-- 此前的判定是 `token ~= nil`:`token=`(空串)也能通过,等于没有校验。
|
||||
-- 取不到 authtoken 时(老版本 LuCI / 非标准上下文)退回原判定,
|
||||
-- 不能因为拿不到框架内部字段就把所有表单都拒掉。
|
||||
local function post_ok()
|
||||
-- 拒绝缺失 CSRF token 的 POST
|
||||
local http = require("luci.http")
|
||||
return fv(http, "token") ~= nil
|
||||
local tok = fv(http, "token")
|
||||
if tok == nil then
|
||||
post_fail_msg = "请求校验失败(缺少 token),请刷新页面后重试"
|
||||
return false
|
||||
end
|
||||
local ok, authtoken = pcall(function()
|
||||
return require("luci.dispatcher").context.authtoken
|
||||
end)
|
||||
if ok and type(authtoken) == "string" and authtoken ~= "" and tok ~= authtoken then
|
||||
post_fail_msg = "请求校验失败(token 不匹配),请刷新页面后重试"
|
||||
return false
|
||||
end
|
||||
post_fail_msg = nil
|
||||
return true
|
||||
end
|
||||
|
||||
-- read cron fields from form, validate cron expression, return cron_enable cron_time
|
||||
@@ -90,6 +118,20 @@ local function read_cron_fields()
|
||||
return cron_enable, cron_time
|
||||
end
|
||||
|
||||
-- read subscription client type (User-Agent) from form, return ua string, err
|
||||
--
|
||||
-- 部分机场按 User-Agent 决定返回真实节点还是占位内容。表单提交的是
|
||||
-- 「预设 key(ua_preset)+ 自定义文本(ua_custom)」,这里解析成最终要存的 UA。
|
||||
-- 非法值必须回显(与规则校验同理):静默存空会让用户以为设置生效了,
|
||||
-- 实际更新出来的仍是占位节点。
|
||||
local function read_ua_fields()
|
||||
local http = require("luci.http")
|
||||
local core = require("substore.core")
|
||||
local ua, err = core.resolve_user_agent(fv(http, "ua_preset"), fv(http, "ua_custom"))
|
||||
if not ua then return nil, err end
|
||||
return ua
|
||||
end
|
||||
|
||||
-- read subscription level rules from form, return rules table, err
|
||||
--
|
||||
-- 第二个返回值是**用户可见**的校验错误。目前只有重命名规则的正则需要校验:
|
||||
@@ -135,8 +177,10 @@ function action_create()
|
||||
-- 规则校验失败(如非法正则)必须回显,否则用户看到的是「规则没生效」
|
||||
local rules, rules_err = read_rules_fields()
|
||||
if not rules then return back_to_list(rules_err or "规则无效") end
|
||||
local user_agent, ua_err = read_ua_fields()
|
||||
if user_agent == nil then return back_to_list(ua_err or "订阅客户端类型无效") end
|
||||
local id, err = core.add(name, url, {
|
||||
proxy_enable = proxy_enable, proxy = proxy,
|
||||
proxy_enable = proxy_enable, proxy = proxy, user_agent = user_agent,
|
||||
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,
|
||||
@@ -145,7 +189,7 @@ function action_create()
|
||||
if not id then return back_to_list(err or "创建订阅失败") end
|
||||
core.write_cron()
|
||||
end
|
||||
back_to_list()
|
||||
back_to_list(post_fail_msg)
|
||||
end
|
||||
|
||||
function action_save()
|
||||
@@ -166,8 +210,11 @@ function action_save()
|
||||
-- 规则校验失败(如非法正则)必须回显,否则用户看到的是「规则没生效」
|
||||
local rules, rules_err = read_rules_fields()
|
||||
if not rules then return back_to_list(rules_err or "规则无效") end
|
||||
local user_agent, ua_err = read_ua_fields()
|
||||
if user_agent == nil then return back_to_list(ua_err or "订阅客户端类型无效") end
|
||||
local ok, err = core.save_meta(id, {
|
||||
name = name, url = url, proxy_enable = proxy_enable, proxy = proxy,
|
||||
user_agent = user_agent,
|
||||
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,
|
||||
@@ -176,7 +223,7 @@ function action_save()
|
||||
if not ok then return back_to_list(err or "保存失败") end
|
||||
core.write_cron()
|
||||
end
|
||||
back_to_list()
|
||||
back_to_list(post_fail_msg)
|
||||
end
|
||||
|
||||
function action_local_create()
|
||||
@@ -201,7 +248,7 @@ function action_local_create()
|
||||
})
|
||||
if not id then return back_to_list(err or "创建本地订阅失败") end
|
||||
end
|
||||
back_to_list()
|
||||
back_to_list(post_fail_msg)
|
||||
end
|
||||
|
||||
function action_local_save()
|
||||
@@ -233,7 +280,7 @@ function action_local_save()
|
||||
local sok, serr = core.sync(id)
|
||||
if not sok then return back_to_list(serr or "解析订阅内容失败") end
|
||||
end
|
||||
back_to_list()
|
||||
back_to_list(post_fail_msg)
|
||||
end
|
||||
|
||||
function action_delete()
|
||||
@@ -243,11 +290,41 @@ function action_delete()
|
||||
-- §18:删除失败必须让用户看见。core.remove 对「非法 ID / 订阅不存在」
|
||||
-- 返回 false(或 false, err),此前返回值被整个丢弃 —— 用户点了删除,
|
||||
-- 页面正常跳回,订阅却还在,看起来像「删了但没生效」。
|
||||
local ok, err = core.remove(fv(http, "id") or "")
|
||||
if not ok then return back_to_list(err or "删除失败:订阅不存在") end
|
||||
core.write_cron()
|
||||
--
|
||||
-- id 支持单个(行内删除按钮)或逗号分隔多个(勾选批量删除),
|
||||
-- 与节点页 action_node_delete 的 idx 同一套约定。订阅 id 是 "s%08x",
|
||||
-- 不含逗号,切分不会切坏。
|
||||
local ids = {}
|
||||
for s in tostring(fv(http, "id") or ""):gmatch("[^,%s]+") do
|
||||
ids[#ids + 1] = s
|
||||
end
|
||||
if #ids == 0 then return back_to_list("未指定要删除的订阅") end
|
||||
local removed, first_err = 0, nil
|
||||
for _, id in ipairs(ids) do
|
||||
local ok, err = core.remove(id)
|
||||
if ok then
|
||||
removed = removed + 1
|
||||
elseif not first_err then
|
||||
first_err = err
|
||||
end
|
||||
end
|
||||
-- 部分失败也必须说:勾了 5 个只删掉 3 个却显示「成功」,
|
||||
-- 用户不会再回头管剩下那 2 个。
|
||||
--
|
||||
-- 只要**有订阅真的被删掉**就要重写 cron —— 部分失败的分支也不例外:
|
||||
-- 被删掉的订阅的 cron 行若留着,substore-cron.sh 会拿着已不存在的 id
|
||||
-- 反复执行,每次都以非 0 退出(见脚本末尾的 FAILED 判断),
|
||||
-- 在日志里刷失败、并让监控误报。
|
||||
if removed > 0 then core.write_cron() end
|
||||
if removed < #ids then
|
||||
if removed == 0 then
|
||||
return back_to_list(first_err or "删除失败:订阅不存在")
|
||||
end
|
||||
return back_to_list(string.format("已删除 %d 个,另有 %d 个删除失败",
|
||||
removed, #ids - removed))
|
||||
end
|
||||
end
|
||||
back_to_list()
|
||||
back_to_list(post_fail_msg)
|
||||
end
|
||||
|
||||
-- 节点页返回链接:保留当前筛选参数
|
||||
@@ -300,7 +377,7 @@ function action_node_save()
|
||||
end
|
||||
core.refresh_combos(id)
|
||||
end
|
||||
back_to_nodes(http)
|
||||
back_to_nodes(http, post_fail_msg)
|
||||
end
|
||||
|
||||
-- 节点删除:idx 支持单个(行内删除按钮)或逗号分隔多个(勾选批量删除)
|
||||
@@ -340,7 +417,7 @@ function action_node_delete()
|
||||
core.save_meta(id, { node_count = #nodes })
|
||||
core.refresh_combos(id)
|
||||
end
|
||||
back_to_nodes(http)
|
||||
back_to_nodes(http, post_fail_msg)
|
||||
end
|
||||
|
||||
-- 单节点分组快速设置(XHR,JSON 响应)
|
||||
@@ -366,6 +443,9 @@ function action_node_set_group()
|
||||
http.write(util.json_encode({ ok = false, err = "write failed" }))
|
||||
return
|
||||
end
|
||||
-- 与 node_save / node_delete 一致:group 是组合订阅筛选与重命名规则的输入,
|
||||
-- 改了不同步组合的话,组合的下载链接会一直吐旧分组的数据。
|
||||
core.refresh_combos(id)
|
||||
http.write(util.json_encode({ ok = true }))
|
||||
end
|
||||
|
||||
@@ -404,7 +484,7 @@ function action_combo_save()
|
||||
end
|
||||
if not nid then return back_to_list(err or "保存组合订阅失败") end
|
||||
end
|
||||
back_to_list()
|
||||
back_to_list(post_fail_msg)
|
||||
end
|
||||
|
||||
function action_update()
|
||||
@@ -431,7 +511,7 @@ function action_update()
|
||||
core.save_meta(id, { error = tostring(err or "更新失败"), last_update = os.time() })
|
||||
end
|
||||
end
|
||||
back_to_list()
|
||||
back_to_list(post_fail_msg)
|
||||
end
|
||||
|
||||
-- node probe endpoint: POST id + mode ping tcping url + proto + keyword, return JSON
|
||||
|
||||
@@ -21,6 +21,10 @@
|
||||
end
|
||||
local rm = (it and it.rename_map) or ""
|
||||
local proxy_enabled = it and (it.proxy_enable == true or it.proxy_enable == "1") or false
|
||||
-- 订阅客户端类型(User-Agent)。部分机场按 UA 决定返回真实节点还是占位内容:
|
||||
-- 同一个订单链接只有用绑定的客户端 UA 才拿得到真节点。预设见 core.UA_PRESETS。
|
||||
local ua_preset = core.ua_preset_of(it and it.user_agent)
|
||||
local ua_custom = (ua_preset == "custom") and (it and it.user_agent or "") or ""
|
||||
local util = require("substore.util")
|
||||
-- 剩余流量 / 剩余时长(仅编辑页且有 subscription-userinfo 数据时显示,首页不显示)
|
||||
local traffic_str = nil
|
||||
@@ -61,6 +65,29 @@
|
||||
<input type="text" id="proxy" name="proxy" value="<%=luci.util.pcdata(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 for="ua_preset" style="display:inline-block;min-width:10em">订阅客户端类型</label>
|
||||
<select id="ua_preset" name="ua_preset" onchange="var r=document.getElementById('ua_custom_row');if(r)r.style.display=this.value=='custom'?'block':'none';">
|
||||
<option value="" <%=ua_preset == "" and "selected" or ""%>>默认(不设置)</option>
|
||||
<% for _, p in ipairs(core.UA_PRESETS) do %>
|
||||
<option value="<%=p.key%>" <%=ua_preset == p.key and "selected" or ""%>><%=p.label%></option>
|
||||
<% end %>
|
||||
<option value="custom" <%=ua_preset == "custom" and "selected" or ""%>>自定义</option>
|
||||
</select>
|
||||
<div style="color:#808080;margin-top:0.25em">
|
||||
部分机场按客户端类型下发节点:同一链接只有选对类型才解析得出真实节点,选错会得到「与您使用客户端不兼容」的占位节点。不确定就保持「默认」。<br/>
|
||||
「自定义」用于预设里没有的客户端:填<b>要原样发送的 User-Agent 请求头</b>,格式与预设里的值同形,即
|
||||
<code>客户端名/版本号</code>,例如 <code>clash-verge/v2.5.0</code>、<code>v2rayN/7.22.0</code>、
|
||||
<code>mihomo.party/v2.0.0 (clash.meta)</code>。<br/>
|
||||
可以含空格、括号、分号、下划线等可见字符(整串原样发送,不解析、不改写);不能含换行等控制字符,
|
||||
最长 256 字符。版本号填<b>机场要求的最低可用版本</b>,具体填什么以机场或客户端说明为准。
|
||||
选「自定义」但留空 = 不发送 User-Agent(等同「默认」)。
|
||||
</div>
|
||||
</div>
|
||||
<div id="ua_custom_row" class="cbi-section-descr" style="margin:0.5em 0;display:<%=ua_preset == "custom" and "block" or "none"%>">
|
||||
<label for="ua_custom" style="display:inline-block;min-width:10em">自定义 User-Agent</label>
|
||||
<input type="text" id="ua_custom" name="ua_custom" value="<%=luci.util.pcdata(ua_custom)%>" 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="cron_enable" value="1" <%=cron_enabled and "checked" or ""%>
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
<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 style="color:#808080;margin-top:0.25em"><%:Paste a node list here — one format at a time (URI / Base64 / YAML / JSON / WireGuard .conf); protocols may be mixed within that format.%></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>
|
||||
@@ -146,6 +146,8 @@ var FIELD_LABELS = {
|
||||
"obfs": "Obfs",
|
||||
"obfs-param": "Obfs Param",
|
||||
"obfs-password": "Obfs Password",
|
||||
// SIP003 插件串,形如 obfs-local;obfs=http;obfs-host=www.baidu.com
|
||||
"plugin": "Plugin (SIP003)",
|
||||
"protocol": "Protocol",
|
||||
"protocol-param": "Protocol Param",
|
||||
"udp": "UDP",
|
||||
|
||||
@@ -66,6 +66,8 @@ var FIELD_LABELS = {
|
||||
"obfs": "Obfs",
|
||||
"obfs-param": "Obfs Param",
|
||||
"obfs-password": "Obfs Password",
|
||||
// SIP003 插件串,形如 obfs-local;obfs=http;obfs-host=www.baidu.com
|
||||
"plugin": "Plugin (SIP003)",
|
||||
"protocol": "Protocol",
|
||||
"protocol-param": "Protocol Param",
|
||||
"udp": "UDP",
|
||||
|
||||
@@ -22,10 +22,20 @@
|
||||
<a class="cbi-button cbi-button-add" href="<%=luci.dispatcher.build_url('admin/services/substore/combo')%>">
|
||||
<%:Add combination%>
|
||||
</a>
|
||||
<!-- 勾选批量删除。与节点页同一套交互:没勾选时点击不做任何删除(只提示)。 -->
|
||||
<button type="button" class="cbi-button cbi-button-reset" onclick="substore_delete_selected()"><%:Delete%></button>
|
||||
</div>
|
||||
|
||||
<!-- 批量删除用的隐藏表单:JS 把选中订阅的 id 收集到 id(逗号分隔)后提交。
|
||||
必须放在表格**外面** —— 每一行里已经各有一个删除表单,HTML 不允许表单嵌套。 -->
|
||||
<form id="batch_delete_form" method="post" action="<%=luci.dispatcher.build_url('admin/services/substore/delete')%>" style="display:none">
|
||||
<input type="hidden" name="token" value="<%=token%>"/>
|
||||
<input type="hidden" name="id" value=""/>
|
||||
</form>
|
||||
|
||||
<table class="cbi-section-table">
|
||||
<tr class="cbi-section-table-titles">
|
||||
<th style="width:1em"><input type="checkbox" onclick="substore_toggle_all(this)"/></th>
|
||||
<th><%:Name%></th>
|
||||
<th><%:URL%></th>
|
||||
<th><%:Nodes%></th>
|
||||
@@ -59,9 +69,17 @@
|
||||
status_text = luci.i18n.translate("OK")
|
||||
end
|
||||
local sub_token = core.ensure_token(it.id)
|
||||
local sub_link_base = prefix .. "?token=" .. sub_token .. "&target="
|
||||
-- ensure_token 在「token 写不进列表文件」时返回 nil:此时链接必然无效,
|
||||
-- 用空 token 拼出来,让下载端报「无效的订阅 token」,
|
||||
-- 而不是在模板里对 nil 做字符串拼接直接 500。
|
||||
local sub_link_base = prefix .. "?token=" .. (sub_token or "") .. "&target="
|
||||
-- 还没解析出节点时禁用格式下拉:刚添加还没点「更新」、更新失败、
|
||||
-- 或解析结果为空的订阅,转出来的内容必然是空的。让用户能选,
|
||||
-- 等于引导他生成一个无效的订阅链接(选了也只是得到一个空结果)。
|
||||
local fmt_disabled = (tonumber(it.node_count) or 0) <= 0
|
||||
%>
|
||||
<tr class="cbi-section-table-row">
|
||||
<td><input type="checkbox" class="sub-check" value="<%=luci.util.pcdata(it.id)%>"/></td>
|
||||
<td>
|
||||
<% if it.combo then %>
|
||||
<span class="cbi-value-description" style="color:#0066aa">[<%:Combination%>]</span> <%=luci.util.pcdata(it.name or "")%>
|
||||
@@ -91,7 +109,9 @@
|
||||
<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%>')">
|
||||
onchange="substore_setlink(this, '<%=it.id%>')"<%
|
||||
if fmt_disabled then %> disabled="disabled" title="<%:Update and parse nodes before choosing a format%>"
|
||||
style="opacity:0.5;cursor:not-allowed"<% end %>>
|
||||
<option value=""><%:Select format%></option>
|
||||
<% for _, fo in ipairs(output.FORMAT_OPTIONS) do %><option value="<%=fo[1]%>"><%=fo[2]%></option><% end %>
|
||||
</select>
|
||||
@@ -103,7 +123,13 @@
|
||||
onclick="var e=document.getElementById('link_<%=it.id%>');e.select();document.execCommand('copy');"><%:Copy%></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cbi-value-description"><%:Pick a format to generate the subscription link; you can switch formats at any time%></div>
|
||||
<div class="cbi-value-description"><%
|
||||
if fmt_disabled then
|
||||
%><%:Update and parse nodes before choosing a format%><%
|
||||
else
|
||||
%><%:Pick a format to generate the subscription link; you can switch formats at any time%><%
|
||||
end
|
||||
%></div>
|
||||
</td>
|
||||
<td>
|
||||
<% if it["local"] then %>
|
||||
@@ -137,7 +163,7 @@
|
||||
if #items == 0 then
|
||||
%>
|
||||
<tr class="cbi-section-table-row">
|
||||
<td colspan="7" style="text-align:center;color:#808080"><%:No subscriptions yet.%></td>
|
||||
<td colspan="8" style="text-align:center;color:#808080"><%:No subscriptions yet.%></td>
|
||||
</tr>
|
||||
<%
|
||||
end
|
||||
@@ -153,5 +179,20 @@ function substore_setlink(sel, id) {
|
||||
inp.value = base + encodeURIComponent(sel.value);
|
||||
wrap.style.display = 'block';
|
||||
}
|
||||
function substore_toggle_all(el) {
|
||||
var boxes = document.querySelectorAll('.sub-check');
|
||||
for (var i = 0; i < boxes.length; i++) boxes[i].checked = el.checked;
|
||||
}
|
||||
function substore_delete_selected() {
|
||||
var boxes = document.querySelectorAll('.sub-check:checked');
|
||||
// 一个都没勾选时什么都不删(否则「删除」会变成「清空全部」)。
|
||||
if (boxes.length === 0) { alert('<%:Please select subscriptions first%>'); return; }
|
||||
if (!confirm('<%=luci.util.pcdata(luci.i18n.translate('Delete selected subscriptions?'))%>')) return;
|
||||
var ids = [];
|
||||
for (var i = 0; i < boxes.length; i++) ids.push(boxes[i].value);
|
||||
var form = document.getElementById('batch_delete_form');
|
||||
form.elements['id'].value = ids.join(',');
|
||||
form.submit();
|
||||
}
|
||||
</script>
|
||||
<%+footer%>
|
||||
@@ -5,6 +5,9 @@
|
||||
"action": {
|
||||
"type": "view",
|
||||
"path": "admin/services/substore/list"
|
||||
},
|
||||
"depends": {
|
||||
"acl": [ "luci-app-substore" ]
|
||||
}
|
||||
},
|
||||
"admin/services/substore/list": {
|
||||
@@ -13,6 +16,9 @@
|
||||
"action": {
|
||||
"type": "view",
|
||||
"path": "admin/services/substore/list"
|
||||
},
|
||||
"depends": {
|
||||
"acl": [ "luci-app-substore" ]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"luci-app-substore": {
|
||||
"description": "Grant UCI access for luci-app-substore",
|
||||
"description": "Grant access to the Subscriptions (luci-app-substore) application",
|
||||
"read": {
|
||||
"uci": [ "substore" ]
|
||||
},
|
||||
|
||||
@@ -1,339 +0,0 @@
|
||||
-- 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,避免直接复用密码。
|
||||
-- 必须是真的 UUID:客户端的 uuid 字段会按 16 字节解析,格式不对时多数客户端
|
||||
-- 直接拒绝该节点。原来写的是 base64(seed) 截前 36 位 —— base64 只产出 24 个
|
||||
-- 字符(`sub(1,36)` 是空操作),里面还可能带 `+` `/` `=`,不是合法 UUID。
|
||||
if src == "trojan" and (tgt == "vmess" or tgt == "vless") then
|
||||
new_node.uuid = util.uuid()
|
||||
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
|
||||
-- 替换值一律按**字面**处理:gsub 的替换串里 `%` 有语义
|
||||
-- (`%1` 是捕获引用,`%%` 才是字面百分号,裸 `%` 会被吞掉或注入 NUL)。
|
||||
-- 节点名来自订阅内容,必须原样落地。
|
||||
local new_name = template
|
||||
new_name = new_name:gsub("{{name}}", util.gsub_literal(cp.name))
|
||||
new_name = new_name:gsub("{{proto}}", util.gsub_literal(cp.proto))
|
||||
new_name = new_name:gsub("{{server}}", util.gsub_literal(cp.server))
|
||||
new_name = new_name:gsub("{{port}}", util.gsub_literal(cp.port))
|
||||
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
|
||||
@@ -7,11 +7,16 @@ local parser = require("substore.parser")
|
||||
|
||||
local M = {}
|
||||
|
||||
M.version = "2.6.9"
|
||||
M.version = "2.7.0"
|
||||
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"
|
||||
-- 列表文件的互斥锁超时(秒)。锁目录固定为 DATA_DIR 下的 .lock,
|
||||
-- 由 with_list_lock 现算 —— 不做成常量:那样任何改了 DATA_DIR 的调用方
|
||||
-- (测试、或将来支持自定义数据目录)都必须记得同步改它,忘了就会去锁真实
|
||||
-- 的 /etc/substore,症状是写入全部报「正被另一个进程修改」。
|
||||
M.LOCK_STALE = util.LOCK_STALE
|
||||
|
||||
M.MAX_SIZE = 10 * 1024 * 1024 -- 10MB
|
||||
M.TIMEOUT = 20
|
||||
@@ -24,6 +29,56 @@ M.TIMEOUT = 20
|
||||
M.RULE_PROTOS = { "vmess", "vless", "trojan", "shadowsocks", "ssr",
|
||||
"hysteria2", "tuic", "hysteria", "wireguard", "socks" }
|
||||
|
||||
-- 「订阅客户端类型」预设。部分机场(如 Allblue 加速器)按 User-Agent 区分客户端:
|
||||
-- 同一个订单链接,只有用**该订单绑定的客户端**的 UA 去请求才返回真实节点,
|
||||
-- 其它 UA 拿到的是「与您使用客户端不兼容」的占位内容 —— 表现为订阅能解析成功,
|
||||
-- 但只有 7 个指向 127.0.0.1:1080 的假节点。
|
||||
--
|
||||
-- 各 UA 字符串取自客户端源码,非猜测:
|
||||
-- clash-verge clash-verge-rev src-tauri/src/utils/network.rs `clash-verge/v{版本}`
|
||||
-- v2rayn v2rayN ServiceLib/Common/Utils.cs `{AppName}/{版本}`(无 v 前缀)
|
||||
-- clash-party Clash Party src/main/config/profile.ts `mihomo.party/v{版本} (clash.meta)`
|
||||
-- flclash FlClash lib/common/package.dart 三段空格分隔,含 Platform/<os>
|
||||
-- 版本号取用户给出的**最低可用版本**;机场若提高门槛,用户可在「自定义」里改。
|
||||
-- 顺序即表单下拉的显示顺序,不要用 pairs 遍历。
|
||||
M.UA_PRESETS = {
|
||||
{ key = "clash-verge", label = "Clash Verge", ua = "clash-verge/v2.5.0" },
|
||||
{ key = "v2rayn", label = "v2rayN", ua = "v2rayN/7.22.0" },
|
||||
{ key = "clash-party", label = "Clash Party", ua = "mihomo.party/v2.0.0 (clash.meta)" },
|
||||
{ key = "flclash", label = "FlClash", ua = "FlClash/v0.8.93 clash-verge Platform/linux" },
|
||||
}
|
||||
|
||||
local UA_BY_KEY = {}
|
||||
for _, p in ipairs(M.UA_PRESETS) do UA_BY_KEY[p.key] = p.ua end
|
||||
|
||||
-- 把表单的 (预设 key, 自定义文本) 解析成最终要发送的 UA 字符串。
|
||||
-- 返回 "" 表示不设置(沿用下载工具自带的 UA),nil + err 表示输入非法。
|
||||
function M.resolve_user_agent(preset, custom)
|
||||
preset = util.trim(preset or "")
|
||||
if preset == "" then return "" end
|
||||
local ua
|
||||
if preset == "custom" then
|
||||
ua = util.trim(custom or "")
|
||||
-- 选了「自定义」却留空 = 明确要求不设置 UA,不是错误。
|
||||
if ua == "" then return "" end
|
||||
else
|
||||
ua = UA_BY_KEY[preset]
|
||||
if not ua then return nil, "未知的订阅客户端类型: " .. preset end
|
||||
end
|
||||
return http.validate_user_agent(ua)
|
||||
end
|
||||
|
||||
-- 反向:把已存的 UA 字符串映射回预设 key(编辑页回显用)。
|
||||
-- 不在预设里的一律落到 "custom",由调用方把原值填进自定义输入框。
|
||||
function M.ua_preset_of(ua)
|
||||
local s = util.trim(ua or "")
|
||||
if s == "" then return "" end
|
||||
for _, p in ipairs(M.UA_PRESETS) do
|
||||
if p.ua == s then return p.key end
|
||||
end
|
||||
return "custom"
|
||||
end
|
||||
|
||||
local function id_is_valid(id)
|
||||
return type(id) == "string" and id ~= "" and id:match("^[A-Za-z0-9_%-]+$") ~= nil
|
||||
end
|
||||
@@ -88,6 +143,41 @@ local function save(seq, items)
|
||||
return util.atomic_write(M.LIST_FILE, util.json_encode({ _seq = seq, items = items }), "600")
|
||||
end
|
||||
|
||||
-- 把「读整表 → 改 → 写整表」串行化。
|
||||
--
|
||||
-- 没有锁时,LuCI 页面保存订阅、cron 定时更新、组合订阅自动重算三者同时发生,
|
||||
-- 后写者会整表覆盖先写者 —— 用户新增的订阅静默消失,且没有任何报错。
|
||||
--
|
||||
-- **可重入**:save_combo 内部会调 save_meta,两者都要保护。若第二次调用再去
|
||||
-- 抢锁,会把自己挡在门外(mkdir 已被本进程建过)并返回「正被占用」,
|
||||
-- 于是嵌套的调用必定失败。所以本进程已持锁时只加计数、不再取锁。
|
||||
-- 跨进程互斥仍由 util.lock_acquire 的 mkdir 保证。
|
||||
local lock_depth = 0
|
||||
local function with_list_lock(f)
|
||||
-- 锁目录是 DATA_DIR 的子目录,父目录不存在时 mkdir 直接失败,
|
||||
-- 而失败在 lock_acquire 眼里等同于「别人正持有」—— 全新安装上第一次
|
||||
-- 保存订阅就会报「正被另一个进程修改」。所以先把目录建出来。
|
||||
M.ensure_dirs()
|
||||
if lock_depth > 0 then
|
||||
lock_depth = lock_depth + 1
|
||||
local a, b = f()
|
||||
lock_depth = lock_depth - 1
|
||||
return a, b
|
||||
end
|
||||
local lock_dir = M.DATA_DIR .. "/.lock"
|
||||
if not util.lock_acquire(lock_dir, { stale = M.LOCK_STALE }) then
|
||||
return nil, "订阅列表正被另一个进程修改,请稍后重试"
|
||||
end
|
||||
lock_depth = 1
|
||||
local a, b = f()
|
||||
lock_depth = 0
|
||||
util.lock_release(lock_dir)
|
||||
return a, b
|
||||
end
|
||||
-- 注:f 抛异常时不会走到释放,锁会留在盘上。这是有意不捕获的 —— 用 pcall
|
||||
-- 包住会把异常改成返回值,调用方(控制器 / cron)看到的错误形态就变了。
|
||||
-- 残留锁由 util.lock_acquire 的陈旧回收兜底:超过 LOCK_STALE 秒后自动可回收。
|
||||
|
||||
-- 返回 arr, err。err 非空表示列表文件已损坏。
|
||||
-- 整体无法解析时 arr 为空;只有部分条目非法时 arr 仍包含能用的条目
|
||||
-- (坏条目已被 load 过滤掉,否则下面的 pairs(meta) 会抛异常)。
|
||||
@@ -134,6 +224,7 @@ function M.add(name, url, opts)
|
||||
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 ""),
|
||||
user_agent = util.trim(opts.user_agent 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,
|
||||
@@ -164,7 +255,7 @@ function M.add_local(name, raw_content, local_mode, opts)
|
||||
name = name, url = "", enabled = true,
|
||||
node_count = 0, last_update = nil, error = "", format = "",
|
||||
token = util.rnd_hex(16),
|
||||
proxy_enable = "0", proxy = "",
|
||||
proxy_enable = "0", proxy = "", user_agent = "",
|
||||
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 ""),
|
||||
@@ -190,8 +281,14 @@ function M.ensure_token(id)
|
||||
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)
|
||||
local tok = util.rnd_hex(16)
|
||||
meta.token = tok
|
||||
-- 没落盘的 token 不能交出去:id_by_token 是从磁盘读的,交出去等于给用户
|
||||
-- 一个必然报「无效的订阅 token」的链接;而且内存表用完即弃,
|
||||
-- 下一次调用会再生成一个**不同**的 token,链接还会跳来跳去。
|
||||
-- 失败就返回 nil,由调用方按「拿不到 token」处理。
|
||||
if not save(seq, items) then return nil, "写入失败" end
|
||||
return tok
|
||||
end
|
||||
return meta.token
|
||||
end
|
||||
@@ -249,8 +346,46 @@ function M.remove(id)
|
||||
if lerr then return false, lerr end
|
||||
if not items[id] then return false end
|
||||
items[id] = nil
|
||||
save(seq, items)
|
||||
-- 引用这个订阅的组合:把死 id 从 sources 里摘掉,同时记下要重算的组合。
|
||||
--
|
||||
-- 必须**在同一趟里**收集,不能摘完再调 M.refresh_combos(id):后者是按
|
||||
-- 「sources 里包含 src_id」来筛组合的,死 id 一旦摘掉就一个都匹配不到,
|
||||
-- 物化节点会一直是旧的 —— 看着改了,其实没修。
|
||||
--
|
||||
-- 摘掉死 id 而不是留着:留着的话列表页「来源」列会显示 s00000003 这种裸 id
|
||||
-- (模板用 name_by_id[sid] or sid 兜底),而且当组合的来源被删光时,
|
||||
-- combo_refresh 看到 #srcs > 0,不会给出「请选择至少一个订阅」,
|
||||
-- 组合会静默变成 0 节点。与列表同一次写盘落盘,不留下中间状态。
|
||||
local affected = {}
|
||||
for cid, c in pairs(items) do
|
||||
local srcs = type(c.sources) == "table" and c.sources or nil
|
||||
if srcs then
|
||||
local kept, hit = {}, false
|
||||
for _, s in ipairs(srcs) do
|
||||
if s == id then hit = true else kept[#kept + 1] = s end
|
||||
end
|
||||
if hit then
|
||||
c.sources = kept
|
||||
affected[#affected + 1] = cid
|
||||
end
|
||||
end
|
||||
end
|
||||
-- 写盘失败必须中止。`items[id] = nil` 只改了内存里的表,磁盘上这条订阅还在:
|
||||
-- 继续往下走会删掉它的节点文件,于是订阅「列表里还在、点进去却空了」,
|
||||
-- 而调用方拿到 true,以为删成功了。
|
||||
-- 组合的 sources 剪除同理:没落盘的改动不重算(重算只会从磁盘读回旧状态)。
|
||||
local ok, serr = save(seq, items)
|
||||
if not ok then return false, serr or "写入失败" end
|
||||
os.remove(M.nodes_file(id))
|
||||
-- 组合的物化节点在 nodes/<combo>.json,只有 combo_refresh 会重写它。
|
||||
-- 不在这里重算的话,组合的下载链接会继续吐已删订阅的节点,一直等到别的源
|
||||
-- 更新(M.sync 里那次 refresh_combos)才被动纠正。
|
||||
--
|
||||
-- 位置与 M.sync 一致:都在写入口内部调用,由 save_meta 进入临界区
|
||||
-- (with_list_lock 可重入,见文件末尾的说明)。
|
||||
for _, cid in ipairs(affected) do
|
||||
M.combo_refresh(cid)
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
@@ -347,7 +482,19 @@ function M.sync(id)
|
||||
-- 日志不记录代理凭据(§39)
|
||||
if proxy ~= "" then log("Using proxy " .. http.redact_proxy(proxy)) end
|
||||
|
||||
local content, headers, err = http.download(meta.url, { max_size = M.MAX_SIZE, timeout = M.TIMEOUT, proxy = proxy })
|
||||
-- 订阅客户端类型(User-Agent):部分机场按 UA 决定返回真实节点还是占位内容。
|
||||
-- 与代理一样,**非法值必须明确失败**:静默忽略会让用户以为 UA 已生效,
|
||||
-- 而实际拿到的是占位节点(§12 禁止 silent fallback)。
|
||||
local ua, uaerr = http.validate_user_agent(meta.user_agent)
|
||||
if not ua then
|
||||
log("User-Agent invalid: " .. tostring(uaerr))
|
||||
M.save_meta(id, { error = "User-Agent 无效: " .. tostring(uaerr), last_update = os.time() })
|
||||
return nil, "User-Agent 无效: " .. tostring(uaerr)
|
||||
end
|
||||
if ua ~= "" then log("Using User-Agent " .. ua) end
|
||||
|
||||
local content, headers, err = http.download(meta.url, { max_size = M.MAX_SIZE, timeout = M.TIMEOUT,
|
||||
proxy = proxy, user_agent = ua })
|
||||
if not content then
|
||||
-- 下载工具的报错可能回显含凭据的 URL,写日志与入库前先抹掉(§39)
|
||||
local safe_err = http.scrub_credentials(err or "下载失败")
|
||||
@@ -415,6 +562,9 @@ local FORM_KEYS = {
|
||||
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,
|
||||
-- SIP003 插件串(shadowsocks)。必须在表里:merge_form_node 只清 FORM_KEYS 里
|
||||
-- 的键,缺了这一项,用户在表单里清空插件输入框也删不掉旧值。
|
||||
plugin = true,
|
||||
["private-key"] = true, private_key = true, ["peer-public-key"] = true, peer_public_key = true,
|
||||
["public-key"] = true, public_key = true, ["pre-shared-key"] = true, preshared_key = true,
|
||||
ip = true, ipv6 = true, ["allowed-ips"] = true, allowed_ips = true,
|
||||
@@ -568,7 +718,7 @@ function M.add_combo(name, sources, opts)
|
||||
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 = "",
|
||||
proxy_enable = "0", proxy = "", user_agent = "",
|
||||
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 ""),
|
||||
@@ -638,9 +788,16 @@ end
|
||||
-- 校验 cron 表达式:5 个字段,每个为数字或 *,防 cron 文件命令注入
|
||||
function M.cron_time_valid(ct)
|
||||
if type(ct) ~= "string" then return false end
|
||||
-- 控制字符一律拒绝。原先用 `%S+` 取词,它把换行也当分隔符:`"1\n 3 * * *"`
|
||||
-- 同样切成 5 个合法词元并通过校验,然后被 write_cron 原样写进
|
||||
-- /etc/cron.d/substore —— 那一行会断成两行,前一行 `1` 不是合法的
|
||||
-- crontab 条目,cron 每次 reload 都报语法错。分隔符必须是单个空格:
|
||||
-- 下面改用整串匹配,制表符 / 多空格 / 换行都落不进来。
|
||||
if ct:find("%c") 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
|
||||
if not ct:match("^%S+ %S+ %S+ %S+ %S+$") then return false end
|
||||
for _, f in ipairs(fields) do
|
||||
if f ~= "*" and not f:match("^%d+$") then return false end
|
||||
end
|
||||
@@ -664,4 +821,31 @@ function M.write_cron()
|
||||
return util.atomic_write(M.CRON_FILE, table.concat(lines, "\n") .. "\n")
|
||||
end
|
||||
|
||||
-- ---------- 写入口的列表锁 ----------
|
||||
--
|
||||
-- 逐个函数改名再包一层,而不是在每个函数体里手写 acquire/release:
|
||||
-- 后者一旦有人在中途 return(`if lerr then return nil, lerr end` 这种)就会漏掉
|
||||
-- 释放,而漏释放的症状是「过一会儿自己好了」(陈旧回收),最难查。
|
||||
-- 包在最外层则无论函数从哪条路径返回都会释放。
|
||||
--
|
||||
-- 只包**写**入口。M.list / M.get / M.read_nodes / M.merge 是只读的(merge 只
|
||||
-- 读各订阅的节点、过滤排序后返回数组,从不写列表),加锁会让每次页面刷新都去
|
||||
-- 抢锁,白白增加失败面 —— 而且失败时它们会返回 nil 而不是原本的数组/表,
|
||||
-- 把「拿不到锁」变成调用方眼里的「没有数据」,比不加锁更糟。
|
||||
-- M.sync / M.combo_refresh / M.refresh_combos 自己不写列表(写列表的是它们内部
|
||||
-- 调用的 M.save_meta),因此也不在此列 —— 它们经由被包住的 save_meta 进入临界区。
|
||||
for _, name in ipairs({
|
||||
"add", "add_local", "ensure_token", "save_meta",
|
||||
"remove", "add_combo", "save_combo",
|
||||
}) do
|
||||
local inner = M[name]
|
||||
M[name] = function(...)
|
||||
-- Lua 5.1 不允许在内层函数里直接用外层函数的 `...`,先收进表再展开。
|
||||
-- 这些入口的参数都是位置参数且非 nil(可选参数一律排在最后),
|
||||
-- 不存在中间空洞把 unpack 截断的情况。
|
||||
local args = { ... }
|
||||
return with_list_lock(function() return inner(unpack(args)) end)
|
||||
end
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -130,13 +130,15 @@ function M.parse_url(url)
|
||||
if scheme ~= "http" and scheme ~= "https" then return nil, "仅支持 http/https" end
|
||||
-- 主机部分到第一个 / ? # 为止(RFC 3986)。只剥 "/" 是不够的:
|
||||
-- "http://127.0.0.1?a=1" 会剩下 "127.0.0.1?a=1",它既不是合法主机名也不是
|
||||
-- 数值型 IPv4,DNS 解析必然失败,而解析失败是放行的(fail-open),
|
||||
-- 于是绕过 SSRF 检查;curl 实际连的是 127.0.0.1(路由器上的 LuCI 就是 :80)。
|
||||
-- 数值型 IPv4,DNS 必然解析不出来,能不能拦住就全看本机有没有解析能力
|
||||
-- (见 check_public 的 fail-open 分支)—— 不能指望;curl 实际连的是
|
||||
-- 127.0.0.1(路由器上的 LuCI 就是 :80)。
|
||||
local hostport = rest:match("^[^/%?#]*") or ""
|
||||
-- 剥掉 userinfo(user:pass@):RFC 3986 里 userinfo 到**最后一个** @ 为止,
|
||||
-- @ 之后才是主机。不剥的话 "http://evil@127.0.0.1/" 会把 "evil@127.0.0.1"
|
||||
-- 当成主机名交给 check_public,DNS 解析失败即放行(fail-open),
|
||||
-- 而 curl 实际连的是 127.0.0.1 —— 实测可复现。
|
||||
-- 当成主机名交给 check_public:它不是合法域名,本机有解析能力时会被
|
||||
-- 判为「无法解析」而拒绝,但本机没有解析能力时是放行的 —— 不能指望
|
||||
-- 那一层兜住。而 curl 实际连的是 127.0.0.1 —— 实测可复现。
|
||||
local at = hostport:find("@", 1, true)
|
||||
while at do
|
||||
hostport = hostport:sub(at + 1)
|
||||
@@ -168,6 +170,23 @@ end
|
||||
|
||||
-- 校验并规范化代理地址:支持 http/https/socks4/socks5/socks5h,可含 user:pass@
|
||||
-- 空串 → ""(未使用);合法 → 规范化地址;非法 → nil, err
|
||||
-- 部分机场按 User-Agent 区分客户端:同一个订单链接,只有用「该订单绑定的客户端」
|
||||
-- 的 UA 去请求才返回真实节点,其余 UA 得到一段占位内容(见 README「订阅客户端类型」)。
|
||||
-- 这里只做「能不能安全地放进命令行」的校验,不判断 UA 是否符合某家客户端。
|
||||
--
|
||||
-- 拒绝控制字符:UA 最终会以 `-A <ua>` 的形式进入 shell 命令,util.shq 的单引号
|
||||
-- 能挡住注入,但挡不住 UA 里的换行 —— curl 会把它当成**响应头之外的**新请求头
|
||||
-- 拼进去(header injection),且日志里也会被换行截断、伪造出额外行。
|
||||
-- 长度上限 256:真实 UA 都在 100 字符内,超长的只可能是构造出来的。
|
||||
function M.validate_user_agent(ua)
|
||||
if ua == nil then return "" end
|
||||
local s = util.trim(tostring(ua))
|
||||
if s == "" then return "" end
|
||||
if #s > 256 then return nil, "User-Agent 过长(最多 256 字符)" end
|
||||
if s:find("%c") then return nil, "User-Agent 不能包含控制字符" end
|
||||
return s
|
||||
end
|
||||
|
||||
function M.parse_proxy(p)
|
||||
local s = util.trim(p or "")
|
||||
if s == "" then return "" end
|
||||
@@ -222,20 +241,71 @@ function M.parse_proxy(p)
|
||||
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
|
||||
-- ---------- 主机名解析 ----------
|
||||
-- 解析手段按代价逐级回退:nixio(进程内,不 fork)→ busybox nslookup(fork 一次)。
|
||||
local function have(cmd)
|
||||
return os.execute("command -v " .. cmd .. " >/dev/null 2>&1") == 0
|
||||
end
|
||||
|
||||
-- busybox nslookup 的输出形如:
|
||||
-- Server: 192.168.1.1
|
||||
-- Address: 192.168.1.1:53
|
||||
--
|
||||
-- Name: example.com
|
||||
-- Address: 93.184.216.34
|
||||
-- Address: 2606:2800:220:1:248:1893:25c8:1946
|
||||
-- 只取 "Name:" 之后的 "Address:" 行:前面那两行是 **DNS 服务器自己**的地址
|
||||
-- (路由器上通常就是 192.168.x.x),当成解析结果会让每个域名都被判成内网而全部误拒。
|
||||
-- 解析失败(NXDOMAIN / 超时)时 busybox 只往 stderr 写错误,stdout 里没有 Name: 段,
|
||||
-- ips 为空 → 返回 nil,与「解析失败」语义一致。
|
||||
local function resolve_via_nslookup(host)
|
||||
local p = io.popen("nslookup " .. util.shq(host) .. " 2>/dev/null")
|
||||
if not p then return nil end
|
||||
local out = p:read("*a") or ""
|
||||
p:close()
|
||||
local ips, in_answer = {}, false
|
||||
for line in out:gmatch("[^\r\n]+") do
|
||||
if line:match("^%s*Name%s*:") then
|
||||
in_answer = true
|
||||
elseif in_answer then
|
||||
local a = line:match("^%s*Address%s*:%s*(%S+)")
|
||||
if a then
|
||||
a = a:match("^([^%%]+)") or a -- 去掉 IPv6 的 %zone 后缀
|
||||
if a ~= "" then ips[#ips + 1] = a end
|
||||
end
|
||||
end
|
||||
end
|
||||
if #ips == 0 then return nil end
|
||||
return ips
|
||||
end
|
||||
|
||||
-- 解析主机 → IP 列表。返回 ips, have_resolver
|
||||
-- ips 解析结果;解析不出来为 nil
|
||||
-- have_resolver 本机是否存在**可用**的解析手段
|
||||
-- 第二个返回值必须与第一个分开:「解析失败」和「本机根本没有解析能力」是两回事,
|
||||
-- 处置方式完全相反(见 check_public),合并成一个 nil 会得出错误结论。
|
||||
local function resolve(host)
|
||||
local have_resolver = false
|
||||
local nixio = util.try_require("nixio")
|
||||
if nixio then
|
||||
have_resolver = true
|
||||
local ok, addrs = pcall(function() return nixio.getaddrinfo(host) end)
|
||||
if ok and type(addrs) == "table" then
|
||||
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 ips, true end
|
||||
end
|
||||
end
|
||||
if have("nslookup") then
|
||||
have_resolver = true
|
||||
local ips = resolve_via_nslookup(host)
|
||||
if ips then return ips, true end
|
||||
end
|
||||
return nil, have_resolver
|
||||
end
|
||||
|
||||
-- 回环别名(/etc/hosts 常见写法),比较前会先去掉尾部点
|
||||
local LOOPBACK_NAMES = {
|
||||
["localhost"] = true,
|
||||
@@ -249,7 +319,9 @@ local LOOPBACK_NAMES = {
|
||||
["ip6-localnet"] = true,
|
||||
}
|
||||
|
||||
-- SSRF 预检:拒绝 localhost / 私网 / 保留地址。返回 ok, reason
|
||||
-- SSRF 预检:拒绝 localhost / 私网 / 保留地址。返回 ok, reason, unverified
|
||||
-- unverified 为 true 表示「本机没有任何 DNS 解析手段,这次放行没有经过解析校验」。
|
||||
-- 调用方(下载层)据此在连接建立后强制复核对端地址,见 verify_peer_ip。
|
||||
function M.check_public(host)
|
||||
host = util.trim(host or ""):lower()
|
||||
if host == "" then return false, "空主机名" end
|
||||
@@ -280,21 +352,31 @@ function M.check_public(host)
|
||||
|
||||
if LOOPBACK_NAMES[host] then return false, "目标为 localhost" end
|
||||
|
||||
local ips = resolve(host)
|
||||
local ips, have_resolver = resolve(host)
|
||||
if not ips then
|
||||
-- 解析不出 IP 一律拒绝(fail-closed)。
|
||||
if have_resolver then
|
||||
-- 本机有解析手段却解析不出来 → 域名确实不存在(NXDOMAIN)。
|
||||
-- 拒绝(fail-closed)。
|
||||
--
|
||||
-- 不能在这里放行:`127.0.0.1.nip.io` 这类**公网可解析到内网**的域名,
|
||||
-- 只要本机这一刻解析不出来(解析器临时故障、超时),就绕过了上面
|
||||
-- 全部检查被放行,而 curl 自己仍会把它解析到 127.0.0.1 并连上去。
|
||||
-- 检查的强度不能低于被检查者。
|
||||
return false, "无法解析目标主机名"
|
||||
end
|
||||
-- 本机连一个解析手段都没有(nixio 不可用,且没有 nslookup)。
|
||||
--
|
||||
-- 此前这里放行,理由是「无 DNS 解析能力时尽力而为」—— 但那是一个
|
||||
-- SSRF 绕过口:`127.0.0.1.nip.io` 这类**公网可解析到内网**的域名,
|
||||
-- 只要本机这一刻解析不出来(nixio 缺失、解析器临时故障、超时),
|
||||
-- 就绕过上面全部检查被放行,而 curl 自己仍会把它解析到 127.0.0.1
|
||||
-- 并连上去。检查的强度不能低于被检查者。
|
||||
-- 这种情形**不能**按「解析失败」处理:[2.6.8-r1] 把两者合并成了同一个
|
||||
-- nil,于是这种设备上**所有**域名订阅都被拒,报「无法解析目标主机名」——
|
||||
-- 而 curl/wget 自带 libc 解析器,照样能解析并下载,2.6.7-r1 实测正常。
|
||||
-- 「本机没装 luci-lib-nixio」不等于「这个域名解析不出来」,两者被混为一谈
|
||||
-- 就是那次回归的根因。
|
||||
--
|
||||
-- 代价为零:本包依赖 luci-lua-runtime,后者硬依赖 luci-lib-nixio,
|
||||
-- 所以 resolve() 返回 nil 只意味着「解析真的失败了」—— 那种情况下
|
||||
-- curl 同样解析不了,下载本来就会失败,只是错误信息会变成
|
||||
-- 「连接失败」这种指错方向的说法。
|
||||
return false, "无法解析目标主机名"
|
||||
-- 放行,改由下载层在**连接建立之后**用 curl 回报的 %{remote_ip} 复核
|
||||
-- 实际对端地址(见 verify_peer_ip)。那才是真正连上去的那个 IP,
|
||||
-- 比预检更贴近事实,顺带免疫「预检时解析到公网、连接时解析到内网」的
|
||||
-- DNS rebinding。
|
||||
return true, "本机无 DNS 解析能力,改由连接时校验对端地址", true
|
||||
end
|
||||
for _, ip in ipairs(ips) do
|
||||
if not is_private(ip) then return true end
|
||||
@@ -303,10 +385,6 @@ function M.check_public(host)
|
||||
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
|
||||
@@ -385,12 +463,46 @@ function M.scrub_credentials(text)
|
||||
return (tostring(text or ""):gsub("//([^%s/@:]*)%:([^%s/@]*)@", "//***@"))
|
||||
end
|
||||
|
||||
-- 连接建立后复核**实际**对端地址,返回 ok, err。
|
||||
--
|
||||
-- check_public 是预检,它解析的是「此刻」的 DNS;curl 稍后会自己再解析一次,
|
||||
-- 两次结果未必相同(DNS rebinding、TTL 过期、多 A 记录轮询、本机无解析手段时的
|
||||
-- 直接放行)。curl 的 %{remote_ip} 回报的是真正建立连接的那个 IP ——
|
||||
-- 用它复核,SSRF 防护才不依赖「预检那一刻的 DNS 恰好和连接时一致」。
|
||||
--
|
||||
-- 走代理时 %{remote_ip} 是代理的地址(多半就在内网),必须跳过:代理是用户
|
||||
-- 自己配置的,不属于 SSRF 防护要拦的目标。
|
||||
local function verify_peer_ip(ip, proxy, unverified)
|
||||
if proxy and proxy ~= "" then return true end
|
||||
if not ip or ip == "" then
|
||||
-- curl 没回报对端地址(连接没建立,或 curl < 7.29 不支持该变量)。
|
||||
if unverified then
|
||||
-- 预检时本机就没有解析能力(check_public 放行了),现在连对端地址
|
||||
-- 也拿不到 —— 这个请求从头到尾没有任何一处校验过目标,
|
||||
-- 必须拒绝,否则「无法校验」就等于「放行」。
|
||||
return false, "无法校验目标地址(本机无 DNS 解析能力,且未取得对端 IP)"
|
||||
end
|
||||
-- 有解析能力时预检已经查过一轮,这里无从复核不额外拒绝。
|
||||
return true
|
||||
end
|
||||
if is_private(ip) then
|
||||
return false, "目标实际连接到内网/保留地址 (" .. ip .. ")"
|
||||
end
|
||||
return true
|
||||
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 = " -x " .. util.shq(opts.proxy)
|
||||
end
|
||||
-- 订阅客户端类型(User-Agent)。空 = 不传,curl 用自带的 curl/x.y.z。
|
||||
-- 必须在**每一跳**都带上:重定向后的目标同样按 UA 决定返回什么内容。
|
||||
local ua_arg = ""
|
||||
if opts.user_agent and opts.user_agent ~= "" then
|
||||
ua_arg = " -A " .. util.shq(opts.user_agent)
|
||||
end
|
||||
-- 临时文件名带随机标记:固定路径会让两个并发下载(如两个订阅的 cron 同时触发,
|
||||
-- 或手动更新撞上 cron)互相覆盖,A 订阅存下 B 的内容且都不报错。
|
||||
local tag = util.rnd_hex(8)
|
||||
@@ -409,13 +521,17 @@ local function fetch_curl(url, parsed, opts)
|
||||
os.remove(tmp); os.remove(hdr); os.remove(errf)
|
||||
end
|
||||
cleanup()
|
||||
-- -w 同时取 http_code 与 remote_ip:后者是真正建立连接的对端地址,
|
||||
-- 用来在拿到响应体之前复核目标(见 verify_peer_ip)。
|
||||
local cmd = string.format(
|
||||
"curl -sS -o %s --max-time %d --connect-timeout %d --max-redirs 0 --max-filesize %d -D %s -w \"%%{http_code}\"%s %s 2>%s",
|
||||
util.shq(tmp), t, math.min(t, 10), max, util.shq(hdr), proxy_arg, util.shq(cur), util.shq(errf))
|
||||
"curl -sS -o %s --max-time %d --connect-timeout %d --max-redirs 0 --max-filesize %d -D %s -w \"%%{http_code} %%{remote_ip}\"%s%s %s 2>%s",
|
||||
util.shq(tmp), t, math.min(t, 10), max, util.shq(hdr), proxy_arg, ua_arg, util.shq(cur), util.shq(errf))
|
||||
local p = io.popen(cmd)
|
||||
local code = p and p:read("*a") or ""
|
||||
local raw = util.trim(p and p:read("*a") or "")
|
||||
if p then p:close() end
|
||||
code = util.trim(code)
|
||||
-- 正常是 "200 93.184.216.34";连接没建立时 curl 只回 "000"(没有 IP)。
|
||||
local code, peer = raw:match("^(%d+)%s*(%S*)$")
|
||||
if not code then code = raw end
|
||||
if code == "" or code == "000" then
|
||||
local msg = util.trim(util.read_file(errf) or "下载失败")
|
||||
cleanup()
|
||||
@@ -425,6 +541,11 @@ local function fetch_curl(url, parsed, opts)
|
||||
-- 或一段 HTML)被当成订阅内容存下去:已存的节点被清空、node_count 归 0,
|
||||
-- 而 error 仍是空字符串,列表页看不出任何异常。下面的重定向分支也因此永远不会执行。
|
||||
if code:match("^2%d%d$") then
|
||||
local pok, perr = verify_peer_ip(peer, opts.proxy, opts.unverified)
|
||||
if not pok then
|
||||
cleanup()
|
||||
return nil, perr
|
||||
end
|
||||
local size = util.file_size(tmp)
|
||||
if size > max then
|
||||
cleanup()
|
||||
@@ -437,6 +558,14 @@ local function fetch_curl(url, parsed, opts)
|
||||
return content, headers
|
||||
end
|
||||
if code:match("^3%d%d$") then
|
||||
-- 重定向的**发起方**也要复核:否则一个公网域名可以先 302 到
|
||||
-- 127.0.0.1 并在第一跳就连上内网服务(Location 检查只能拦住
|
||||
-- 第二跳的目标,拦不住第一跳本身)。
|
||||
local pok, perr = verify_peer_ip(peer, opts.proxy, opts.unverified)
|
||||
if not pok then
|
||||
cleanup()
|
||||
return nil, perr
|
||||
end
|
||||
local loc = location_from_headers(hdr)
|
||||
if not loc then
|
||||
cleanup()
|
||||
@@ -478,17 +607,31 @@ function M.wget_proxy_env(proxy)
|
||||
end
|
||||
|
||||
local function fetch_wget(url, parsed, opts)
|
||||
-- 本机没有 DNS 解析能力时,check_public 只能 fail-open 放行,把校验推迟到
|
||||
-- 「连接建立后复核对端地址」。curl 后端有 %{remote_ip} 可用(见 verify_peer_ip),
|
||||
-- wget 后端**没有任何等价物**:拿不到对端地址,busybox wget 也没有可用的
|
||||
-- 重定向拦截(重定向链只能事后从 -S 日志里看,拦不住已经发出去的请求)。
|
||||
-- 于是在这条路径上,「预检放行」之后不存在任何一处校验 —— 等于没有 SSRF 防护。
|
||||
-- 与 verify_peer_ip 的处置保持一致:校验不了就拒绝,而不是放行。
|
||||
if opts.unverified then
|
||||
return nil, "本机无 DNS 解析能力,wget 后端无法校验目标地址,已拒绝下载(安装 curl 后重试)"
|
||||
end
|
||||
local max, t = opts.max_size, opts.timeout
|
||||
local proxy_env, perr = M.wget_proxy_env(opts.proxy)
|
||||
if not proxy_env then return nil, perr end
|
||||
-- busybox wget 用 -U 设置 User-Agent(已核对 busybox 1.37 的 --help)。
|
||||
local ua_arg = ""
|
||||
if opts.user_agent and opts.user_agent ~= "" then
|
||||
ua_arg = " -U " .. util.shq(opts.user_agent)
|
||||
end
|
||||
local tag = util.rnd_hex(8)
|
||||
local tmp = "/tmp/substore_dl_wget_" .. tag .. ".tmp"
|
||||
local log = tmp .. ".log"
|
||||
os.remove(tmp); os.remove(log)
|
||||
-- -S 打印响应头(含整条重定向链),据此复检 SSRF;
|
||||
-- 日志与响应体分流:体写 tmp,链写 log
|
||||
local cmd = proxy_env .. string.format("wget -S -q -T %d -O %s %s >%s 2>&1",
|
||||
t, util.shq(tmp), util.shq(url), util.shq(log))
|
||||
local cmd = proxy_env .. string.format("wget -S -q -T %d%s -O %s %s >%s 2>&1",
|
||||
t, ua_arg, util.shq(tmp), util.shq(url), util.shq(log))
|
||||
local rc = os.execute(cmd)
|
||||
-- 退出码必须看:wget 失败时可能已经写下半截响应体,只判断 size == 0
|
||||
-- 会把残缺内容当成下载成功。
|
||||
@@ -521,17 +664,21 @@ end
|
||||
|
||||
-- 下载订阅内容。成功返回 body, headers, nil;失败返回 nil, nil, err
|
||||
-- opts.proxy 为可选代理地址(scheme://host:port),应由调用方用 parse_proxy 校验
|
||||
-- opts.user_agent 为可选的订阅客户端 User-Agent,应由调用方用 validate_user_agent 校验
|
||||
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)
|
||||
local ua, uaerr = M.validate_user_agent(opts.user_agent)
|
||||
if not ua then return nil, nil, uaerr end
|
||||
local ok, reason, unverified = 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 })
|
||||
local body, headers = fetch(tool, url, parsed, { max_size = max_size, timeout = timeout, proxy = opts.proxy,
|
||||
user_agent = ua, unverified = unverified })
|
||||
if not body then return nil, nil, headers end
|
||||
return body, headers or {}, nil
|
||||
end
|
||||
|
||||
@@ -32,7 +32,10 @@ M.PROTO_FIELDS = {
|
||||
vmess = { "server", "port", "uuid", "alterId", "cipher", "net", "headerType", "path", "host", "sni", "security", "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" },
|
||||
-- plugin:SIP003 插件串(`obfs-local;obfs=http;obfs-host=x`)。不进这个清单
|
||||
-- 会有两个后果:表单不渲染它,且 core.merge_form_node 会在保存时把原值清掉
|
||||
-- —— 用户在界面上改一下带插件的 ss 节点,插件配置就永久消失。
|
||||
shadowsocks = { "server", "port", "password", "method", "headerType", "udp", "plugin" },
|
||||
hysteria2 = { "server", "port", "password", "sni", "obfs", "obfs-password", "skip-cert-verify" },
|
||||
-- hysteria(v1):obfs 是普通字符串(不是 hysteria2 的 salamander),因此没有 obfs-password。
|
||||
-- 字段集合以各输出模块实际消费的键为准(output_clash_meta / output_singbox / output_uri)。
|
||||
|
||||
@@ -1,315 +0,0 @@
|
||||
-- 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
|
||||
@@ -73,9 +73,21 @@ local CONTENT_TYPES = {
|
||||
-- 未指定 target 时的默认格式(保持历史行为:Clash.Meta)
|
||||
local DEFAULT_FORMAT = "clashmeta"
|
||||
|
||||
-- 归一 target:空串 / 纯空白等同于「未指定」。
|
||||
-- `?target=` 传进来的是 "",而 "" 在 Lua 里是**真值**,`format or DEFAULT_FORMAT`
|
||||
-- 兜不住它。M.generate 一直在做这件事,但 content_type_for / extension_for 漏了:
|
||||
-- 同一个请求里正文按 clashmeta 生成,而这两个函数拿 "" 去查表全部落空 ——
|
||||
-- Content-Type 变成 nil(响应头缺失),文件名后缀退回兜底的 .txt。下游按后缀
|
||||
-- 判断格式的客户端会把 YAML 当成纯文本,解析失败。
|
||||
local function normalize_format(format)
|
||||
if type(format) ~= "string" then return DEFAULT_FORMAT end
|
||||
format = format:match("^%s*(.-)%s*$") or ""
|
||||
if format == "" then return DEFAULT_FORMAT end
|
||||
return format
|
||||
end
|
||||
|
||||
function M.content_type_for(format)
|
||||
format = (format or DEFAULT_FORMAT) or DEFAULT_FORMAT
|
||||
if type(format) ~= "string" then format = DEFAULT_FORMAT end
|
||||
format = normalize_format(format)
|
||||
local norm = M.FORMAT_ALIASES[format:lower():gsub("[-_%s]", "")]
|
||||
if not norm then norm = M.FORMAT_ALIASES[format:lower()] end
|
||||
return CONTENT_TYPES[norm]
|
||||
@@ -101,8 +113,7 @@ local FILENAME_EXT = {
|
||||
}
|
||||
|
||||
function M.extension_for(format)
|
||||
format = (format or DEFAULT_FORMAT) or DEFAULT_FORMAT
|
||||
if type(format) ~= "string" then format = DEFAULT_FORMAT end
|
||||
format = normalize_format(format)
|
||||
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"
|
||||
@@ -133,9 +144,7 @@ function M.generate(nodes, format, options)
|
||||
-- `?target=` 会传进来 "",而 "" 在 Lua 里是**真值**,所以 `format or DEFAULT_FORMAT`
|
||||
-- 兜不住它,会一路落到 "unsupported format: "(冒号后面什么都没有)——
|
||||
-- 用户拿到的是一个说不出原因的错误页。控制器只在 nil 时兜底,覆盖不到空串。
|
||||
if type(format) ~= "string" then format = "" end
|
||||
format = format:match("^%s*(.-)%s*$") or ""
|
||||
if format == "" then format = DEFAULT_FORMAT end
|
||||
format = normalize_format(format)
|
||||
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
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
-- output_clash_meta.lua — Clash.Meta / Mihomo 格式输出(纯 Lua)
|
||||
-- luci-app-substore
|
||||
|
||||
local util = require("substore.util")
|
||||
|
||||
local M = {}
|
||||
|
||||
local function esc_yaml(s)
|
||||
@@ -10,8 +12,17 @@ local function esc_yaml(s)
|
||||
-- 字符(\n \r \t 等)时必须加引号 —— 未加引号的换行/回车会直接破坏文档结构。
|
||||
local need_quote = s:find("%c") ~= nil
|
||||
-- 注意:这里必须用 Lua 模式(不能传 plain=true),否则整串被当作字面量、永不匹配
|
||||
or s:find("[ :#{}%[%],&*?|>'\"%@`]") ~= nil
|
||||
-- `%` `!` 与开头的 `-` 也是 YAML 的 c-indicator,不能作为 plain scalar 的首字符:
|
||||
-- `password: %foo` → "found character '%' that cannot start any token",
|
||||
-- `name: !x` → 被当成标签("could not determine a constructor for the tag '!x'"),
|
||||
-- 裸 `-` → 被当成块序列条目("sequence entries are not allowed here")。
|
||||
-- 三者都会让客户端拒绝**整份**配置。节点名以 `%`/`!` 开头并不罕见(机场命名
|
||||
-- 很随意),密码里出现 `%` 更是常见,所以必须一起判。
|
||||
or s:find("[ :#{}%[%],&*?|>'\"%@`!%%]") ~= nil
|
||||
or s:match("^[-?]*:") ~= nil
|
||||
-- 首字符是 `-`(含单独的 "-")时同样要加引号:`^[-?]*:` 只覆盖
|
||||
-- 「`-` 后跟冒号」的写法,覆盖不到裸 `-` 与 `-foo`。
|
||||
or s:match("^%-") ~= nil
|
||||
-- 转义只在**加引号时**做:未加引号的 plain scalar 里反斜杠就是字面反斜杠,
|
||||
-- 提前翻倍会让回读得到两个反斜杠(`pa\ss` → 输出 `pa\\ss` → 读回 `pa\\ss`,
|
||||
-- 密码/路径直接错)。\ 本身不在上面的 need_quote 触发集里,所以含反斜杠的
|
||||
@@ -83,10 +94,44 @@ local function get_clash_type(proto)
|
||||
return PROTOCOL_TYPE_MAP[proto] or proto
|
||||
end
|
||||
|
||||
local function format_node(node)
|
||||
-- SIP003 插件 → mihomo 的 plugin / plugin-opts。
|
||||
--
|
||||
-- 名称与参数名都和 SIP003 原文不同,需要逐项翻译:
|
||||
-- obfs-local / simple-obfs / obfs → obfs, 参数 obfs → mode、obfs-host → host
|
||||
-- v2ray-plugin → v2ray-plugin, 参数 mode → mode、host → host、
|
||||
-- path → path、tls → tls
|
||||
--
|
||||
-- mihomo 对这两类插件的参数是**强校验**的(adapter/outbound/shadowsocks.go):
|
||||
-- obfs 的 mode 不在 {tls,http} 里报 "ss %s obfs mode error",v2ray-plugin 的
|
||||
-- mode 不是 websocket 同样报错,两者都会让这个 outbound 构造失败,进而拒绝
|
||||
-- 加载**整份**配置 —— 一个坏节点废掉整个订阅。所以参数不全时宁可整个不输出
|
||||
-- 插件,也不能输出一个必然被拒绝的组合。
|
||||
-- 未知插件名直接忽略:mihomo 的 plugin 分支没有收尾 else,未知名字本来就静默跳过。
|
||||
local function ss_plugin_mihomo(node)
|
||||
local name, _, opts = util.parse_sip003_plugin(node.plugin)
|
||||
if not name then return nil end
|
||||
if name == "obfs-local" or name == "simple-obfs" or name == "obfs" then
|
||||
local mode = opts.obfs or opts.mode
|
||||
if mode ~= "tls" and mode ~= "http" then return nil end
|
||||
local out = { { "mode", mode } }
|
||||
local host = opts["obfs-host"] or opts.host
|
||||
if host then out[#out + 1] = { "host", host } end
|
||||
return "obfs", out
|
||||
elseif name == "v2ray-plugin" then
|
||||
if opts.mode ~= "websocket" then return nil end
|
||||
local out = { { "mode", "websocket" } }
|
||||
if opts.host then out[#out + 1] = { "host", opts.host } end
|
||||
if opts.path then out[#out + 1] = { "path", opts.path } end
|
||||
if opts.tls == true or opts.tls == "true" then out[#out + 1] = { "tls", "true" } end
|
||||
return "v2ray-plugin", out
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function format_node(node, name)
|
||||
local lines = {}
|
||||
local ctype = get_clash_type(node.proto)
|
||||
lines[#lines + 1] = " - name: " .. esc_yaml(node.name or "")
|
||||
lines[#lines + 1] = " - name: " .. esc_yaml(name)
|
||||
lines[#lines + 1] = " type: " .. esc_yaml(ctype)
|
||||
lines[#lines + 1] = " server: " .. esc_yaml(node.server or "")
|
||||
-- 端口必须是数字:`port: abc` / `port: 443/tcp` 这类非数字值会让 mihomo 拒绝
|
||||
@@ -105,6 +150,18 @@ local function format_node(node)
|
||||
lines[#lines + 1] = " cipher: " .. esc_yaml(node.method)
|
||||
end
|
||||
end
|
||||
-- SIP003 插件。ss 节点带 obfs / v2ray-plugin 时,服务端只接受带插件的
|
||||
-- 握手;丢掉这一项导出的配置会以明文 SS 去连,必然失败且客户端不报错。
|
||||
if ctype == "ss" and node.plugin then
|
||||
local plugin_name, plugin_opts = ss_plugin_mihomo(node)
|
||||
if plugin_name then
|
||||
lines[#lines + 1] = " plugin: " .. esc_yaml(plugin_name)
|
||||
lines[#lines + 1] = " plugin-opts:"
|
||||
for _, kv in ipairs(plugin_opts) do
|
||||
lines[#lines + 1] = " " .. esc_yaml(kv[1]) .. ": " .. esc_yaml(kv[2])
|
||||
end
|
||||
end
|
||||
end
|
||||
if node.cipher then
|
||||
if ctype == "vmess" or ctype == "vless" then
|
||||
lines[#lines + 1] = " cipher: " .. esc_yaml(node.cipher)
|
||||
@@ -115,13 +172,22 @@ local function format_node(node)
|
||||
lines[#lines + 1] = " network: " .. esc_yaml(net)
|
||||
-- ws 特殊处理
|
||||
if net == "ws" then
|
||||
if node.path then
|
||||
-- ws-opts 是 path 与 headers 的**共同**父键:只有 host 没有 path 时
|
||||
-- 也必须先把 ws-opts 写出来。原来 ws-opts 行放在 `if node.path` 里面,
|
||||
-- 于是「有 host、无 path」的节点会输出一个缩进 6 空格的 headers,
|
||||
-- 而它的父键根本不存在 —— YAML 直接报
|
||||
-- "mapping values are not allowed here",客户端拒绝整份配置。
|
||||
-- 这条路径是真实可达的:Clash YAML 的 ws-opts.headers.Host 与
|
||||
-- ws-opts.path 是各自独立读取的,表单里也允许只填 host。
|
||||
if node.path or node.host 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)
|
||||
if node.path then
|
||||
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
|
||||
elseif net == "grpc" then
|
||||
-- 服务名写在 grpc-opts 里。只写 network: grpc 的话客户端用默认服务名
|
||||
@@ -291,7 +357,11 @@ local function format_node(node)
|
||||
for k in pairs(node["amnezia-wg-option"]) do keys[#keys + 1] = k end
|
||||
table.sort(keys)
|
||||
for _, k in ipairs(keys) do
|
||||
lines[#lines + 1] = " " .. k .. ": " .. esc_yaml(node["amnezia-wg-option"][k])
|
||||
-- 键名同样要过 esc_yaml。子表是从 Clash YAML / sing-box JSON /
|
||||
-- wireguard:// 原样拷进来的,键名由订阅内容决定,不是我们写死的:
|
||||
-- 键里带换行会在第 0 列插进一行,带 `: ` 会写出 `x: 1: 2` 这种
|
||||
-- 映射值错误 —— 两者都让客户端拒绝整份配置。
|
||||
lines[#lines + 1] = " " .. esc_yaml(k) .. ": " .. esc_yaml(node["amnezia-wg-option"][k])
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -318,26 +388,23 @@ local function format_node(node)
|
||||
return table.concat(lines, "\n")
|
||||
end
|
||||
|
||||
local function generate_proxies(nodes)
|
||||
local function generate_proxies(nodes, tags)
|
||||
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)
|
||||
for i, node in ipairs(nodes) do
|
||||
out[#out + 1] = format_node(node, tags[i])
|
||||
end
|
||||
return table.concat(out, "\n")
|
||||
end
|
||||
|
||||
local function generate_groups(nodes, options)
|
||||
local function generate_groups(nodes, tags, options)
|
||||
local out = {}
|
||||
out[#out + 1] = "proxy-groups:"
|
||||
|
||||
local names = {}
|
||||
for _, n in ipairs(nodes) do
|
||||
names[#names + 1] = n.name or ""
|
||||
end
|
||||
local names = tags
|
||||
|
||||
local group_name = (options and options.name) or "Proxy"
|
||||
|
||||
@@ -386,13 +453,29 @@ end
|
||||
|
||||
function M.generate(nodes, options)
|
||||
options = options or {}
|
||||
nodes = nodes or {}
|
||||
local parts = {}
|
||||
|
||||
-- 重名节点必须改名后再输出。mihomo 的 parseProxies 遇到重复的 proxy 名会
|
||||
-- 直接返回 "proxy %s is the duplicate name" 拒绝**整份**配置;节点重名在
|
||||
-- 订阅里很常见(node.dedup 默认关闭,而且它按 proto+server+port+身份去重,
|
||||
-- 同名不同服务器根本不会被去掉)。生成组名也要一起占位,节点名撞上组名
|
||||
-- 同样是 "proxy group %s: the duplicate name"。
|
||||
-- 与 sing-box / Xray 输出共用 util.unique_tags,两边的改名规则保持一致。
|
||||
local group_name = (options and options.name) or "Proxy"
|
||||
local tags = util.unique_tags(nodes, {
|
||||
[group_name] = true,
|
||||
["URL-Test"] = true,
|
||||
["Load-Balance"] = true,
|
||||
["DIRECT"] = true,
|
||||
["REJECT"] = true,
|
||||
})
|
||||
|
||||
-- 生成 proxies
|
||||
parts[#parts + 1] = generate_proxies(nodes or {})
|
||||
parts[#parts + 1] = generate_proxies(nodes, tags)
|
||||
|
||||
-- 生成 proxy-groups
|
||||
parts[#parts + 1] = generate_groups(nodes or {}, options)
|
||||
parts[#parts + 1] = generate_groups(nodes, tags, options)
|
||||
|
||||
return table.concat(parts, "\n\n")
|
||||
end
|
||||
|
||||
@@ -20,6 +20,17 @@ function M.surge_line(n)
|
||||
local e = {}
|
||||
local tls = (n.security and n.security ~= "none") or (n.tls and n.tls ~= false and n.tls ~= "none")
|
||||
|
||||
-- ws 传输参数。vmess 与 vless 都要输出:这两个协议在 Surge 家族里共用同一套
|
||||
-- 参数名,此前只有 vmess 分支写了,vless + ws 的节点导出后丢掉整个传输层,
|
||||
-- 客户端按 tcp 去连一个只开了 ws 的端口,握手必然失败且不报错。
|
||||
local function add_ws()
|
||||
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
|
||||
end
|
||||
|
||||
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 "")
|
||||
@@ -27,16 +38,13 @@ function M.surge_line(n)
|
||||
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
|
||||
add_ws()
|
||||
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
|
||||
add_ws()
|
||||
elseif proto == "trojan" then
|
||||
e[#e + 1] = "password=" .. (n.password or "")
|
||||
e[#e + 1] = "tls=true"
|
||||
@@ -62,7 +70,14 @@ function M.surge_line(n)
|
||||
-- 直接拼接会 "attempt to concatenate a table value" 让整次导出失败
|
||||
local alpn = n.alpn
|
||||
if type(alpn) == "table" then alpn = table.concat(alpn, ",") end
|
||||
if alpn ~= "" then e[#e + 1] = "alpn=" .. alpn end
|
||||
-- 多值 alpn 在这里表达不了:Surge 家族的行是逗号分隔的 key=value,
|
||||
-- 没有引号 / 转义,`alpn=h3,h2` 会被读成 `alpn=h3` 加一个悬空字段。
|
||||
-- 但 alpn 只是协商提示,缺省时客户端会用服务端给出的列表,
|
||||
-- 不像密码那样一旦被截断就静默发错凭据 —— 所以这里丢掉该参数、
|
||||
-- 保留节点,而不是像下面的通用逗号检查那样整条丢弃。
|
||||
if alpn ~= "" and not alpn:find(",", 1, true) then
|
||||
e[#e + 1] = "alpn=" .. alpn
|
||||
end
|
||||
end
|
||||
elseif proto == "socks5" or proto == "socks" or proto == "http" then
|
||||
-- socks5 / http 在 Surge 家族里都用 username= / password= 具名参数
|
||||
@@ -77,6 +92,17 @@ function M.surge_line(n)
|
||||
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
|
||||
|
||||
-- 参数值里的逗号无法用这些格式表达,整条丢弃(返回 nil,由调用方剔除)。
|
||||
-- [Proxy] 行与 QX 的 [server_local] 行都是逗号分隔的 `key=value` 序列,
|
||||
-- 语法里没有引号 / 转义机制:`password=pa,ss` 会被读成 `password=pa` 加上
|
||||
-- 一个悬空的 `ss` 字段 —— 凭据被静默截断,用户不会收到任何提示。
|
||||
-- 节点名里的逗号已由 names_of 单独处理(不进成员列表),但名字在 `=` 左侧,
|
||||
-- 不影响本行解析;值在右侧,必须在这里挡掉。
|
||||
for _, kv in ipairs(e) do
|
||||
local v = kv:match("^[^=]*=(.*)$")
|
||||
if v and v:find(",", 1, true) then return nil end
|
||||
end
|
||||
|
||||
local line = (n.name or "") .. " = " .. head
|
||||
if #e > 0 then line = line .. ", " .. table.concat(e, ", ") end
|
||||
-- 节点名与各参数值都来自订阅(不可信):含换行会截断本行并伪造出一条新的代理行
|
||||
@@ -124,12 +150,20 @@ local function surge_config(nodes, group_name, supports_ssr)
|
||||
list = kept
|
||||
local out = {}
|
||||
out[#out + 1] = "[Proxy]"
|
||||
-- surge_line 对「值里含逗号」这类无法表达的节点返回 nil,这些节点必须
|
||||
-- 同时从成员列表里剔除 —— 否则 [Proxy Group] 会引用一个不存在的代理,
|
||||
-- Surge 直接拒绝加载整份配置。
|
||||
local rendered = {}
|
||||
for _, n in ipairs(list) do
|
||||
out[#out + 1] = M.surge_line(n)
|
||||
local line = M.surge_line(n)
|
||||
if line then
|
||||
out[#out + 1] = line
|
||||
rendered[#rendered + 1] = n
|
||||
end
|
||||
end
|
||||
out[#out + 1] = ""
|
||||
out[#out + 1] = "[Proxy Group]"
|
||||
local names = names_of(list)
|
||||
local names = names_of(rendered)
|
||||
local select = group_name .. " = select"
|
||||
for _, nm in ipairs(names) do select = select .. ", " .. nm end
|
||||
select = select .. ", DIRECT"
|
||||
@@ -184,6 +218,62 @@ function M.to_clash(nodes, options)
|
||||
return clash_meta.generate(kept, options)
|
||||
end
|
||||
|
||||
-- QX 的 ws / tls 具名参数。vmess 与 vless 在 QX 里共用同一套参数名,
|
||||
-- 两个分支必须写出完全一致的集合 —— 此前 vless 一个都没写,vless + ws + tls
|
||||
-- 的节点导出成明文 tcp 条目,客户端按 tcp 去连只开了 ws 的端口,必然失败且不报错。
|
||||
local function qx_transport(n, f)
|
||||
if n.net == "ws" then
|
||||
f[#f + 1] = "obfs=ws"
|
||||
if n.path then f[#f + 1] = "obfs-uri=" .. n.path end
|
||||
if n.host then f[#f + 1] = "obfs-host=" .. n.host end
|
||||
end
|
||||
end
|
||||
|
||||
local function qx_tls(n, f)
|
||||
if n.sni then f[#f + 1] = "tls-host=" .. n.sni end
|
||||
if n.security and n.security ~= "none" then f[#f + 1] = "tls-verification=true" end
|
||||
end
|
||||
|
||||
-- 单条 [server_local] 定义行。返回 nil 表示该节点无法用 QX 的行语法表达,
|
||||
-- 由调用方整条剔除(定义行与 [policy] 成员一并去掉)。
|
||||
--
|
||||
-- 具名字段先攒成列表再拼接,而不是边拼字符串边追加:值里含逗号的条目在逗号
|
||||
-- 分隔语法里表达不了(`password=pa,ss` 会被读成 `password=pa` 加一个悬空字段,
|
||||
-- 凭据被静默截断),必须能逐字段判定后整条丢弃。若先拼成整行再回头用模式去切,
|
||||
-- 行内本来就有的 `, ` 结构分隔符与值里的逗号无法区分 —— 会把每条合法行都误判成非法。
|
||||
local function qx_server_line(n, tag)
|
||||
local proto = props(n)
|
||||
local host = (n.server or "") .. ":" .. tostring(n.port or 0)
|
||||
local f = {}
|
||||
if proto == "shadowsocks" then
|
||||
f[#f + 1] = "method=" .. (n.method or n.cipher or "aes-256-gcm")
|
||||
f[#f + 1] = "password=" .. (n.password or "")
|
||||
elseif proto == "vmess" then
|
||||
f[#f + 1] = "method=none"
|
||||
f[#f + 1] = "password=" .. (n.uuid or "")
|
||||
qx_transport(n, f)
|
||||
qx_tls(n, f)
|
||||
elseif proto == "vless" then
|
||||
f[#f + 1] = "method=none"
|
||||
f[#f + 1] = "password=" .. (n.uuid or "")
|
||||
qx_transport(n, f)
|
||||
qx_tls(n, f)
|
||||
elseif proto == "trojan" then
|
||||
f[#f + 1] = "password=" .. (n.password or "")
|
||||
f[#f + 1] = "over-tls=true"
|
||||
if n.sni then f[#f + 1] = "tls-host=" .. n.sni end
|
||||
end
|
||||
if #f == 0 then return nil end
|
||||
for _, kv in ipairs(f) do
|
||||
local v = kv:match("^[^=]*=(.*)$")
|
||||
if v and v:find(",", 1, true) then return nil end
|
||||
end
|
||||
-- 节点名 / sni / path / host 全部来自订阅(不可信),含换行会截断本行并
|
||||
-- 伪造出一条新的 server_local 行。tag 排在最后。
|
||||
return util.one_line(
|
||||
proto .. "=" .. host .. ", " .. table.concat(f, ", ") .. ", tag=" .. tag)
|
||||
end
|
||||
|
||||
-- Quantumult X
|
||||
function M.to_qx(nodes, options)
|
||||
options = options or {}
|
||||
@@ -194,42 +284,22 @@ function M.to_qx(nodes, options)
|
||||
local emitted = {}
|
||||
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
|
||||
local before = #out
|
||||
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
|
||||
local tag = util.one_line(n.name or ((n.server or "") .. ":" .. tostring(n.port or 0)))
|
||||
-- 名字里的逗号落在 `tag=` 的值上,而本行是逗号分隔的 `key=value` 序列,
|
||||
-- 语法里没有引号 / 转义:`tag=A,B` 会被读成 `tag=A` 加一个悬空字段。
|
||||
-- QX 对这种行的实际处置未经核实(本机没有 QX 可实测),但无论它是报错
|
||||
-- 还是静默忽略,用户拿到的都不是他填的那个名字 —— 所以整条丢弃,与
|
||||
-- Surge 家族丢 wireguard / ssr、Clash 原版丢不支持协议同一约定,也与
|
||||
-- 下面 [policy] 成员列表的排除条件(names_of)保持一致。
|
||||
-- 注意判定用 one_line 之后的名字:names_of 也是先 one_line 再比对,
|
||||
-- 两边必须用同一个字符串,否则定义行与成员列表会对不上。
|
||||
if not tag:find(",", 1, true) then
|
||||
local line = qx_server_line(n, tag)
|
||||
if line then
|
||||
out[#out + 1] = line
|
||||
emitted[#emitted + 1] = n
|
||||
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
|
||||
-- 节点名 / sni / path / host 全部来自订阅(不可信),含换行会截断本行
|
||||
-- 并伪造出一条新的 server_local 行
|
||||
for i = before + 1, #out do out[i] = util.one_line(out[i]) end
|
||||
-- 上面的 if/elseif 命中时必定追加一行,未命中时一行不加
|
||||
if #out > before then emitted[#emitted + 1] = n end
|
||||
end
|
||||
out[#out + 1] = ""
|
||||
out[#out + 1] = "[policy]"
|
||||
|
||||
@@ -145,6 +145,19 @@ function M.to_outbound(n, tag)
|
||||
elseif stype == "shadowsocks" then
|
||||
o.method = n.method or n.cipher or "aes-256-gcm"
|
||||
o.password = n.password or ""
|
||||
-- SIP003 插件。sing-box 的 shadowsocks 出站只有 plugin(字符串)与
|
||||
-- plugin_opts(字符串)两个字段,plugin_opts 就是 SIP003 的原始参数串
|
||||
-- (`obfs=http;obfs-host=x`),不做任何翻译 —— 与 mihomo 的映射式
|
||||
-- plugin-opts 正好相反。
|
||||
-- 官方文档明确「Only two are supported: obfs-local and v2ray-plugin」,
|
||||
-- 其余名字(kcptun / shadow-tls / restls / gost-plugin …)sing-box
|
||||
-- 认不出来,会拒绝加载整份配置,所以按白名单过滤。
|
||||
-- 丢掉这一项时服务端只接受带插件的握手,导出的配置必然连不上且不报错。
|
||||
local pname, popts = util.parse_sip003_plugin(n.plugin)
|
||||
if pname == "obfs-local" or pname == "v2ray-plugin" then
|
||||
o.plugin = pname
|
||||
if popts and popts ~= "" then o.plugin_opts = popts end
|
||||
end
|
||||
elseif stype == "hysteria2" then
|
||||
o.password = n.password or ""
|
||||
-- 混淆(salamander):hysteria2 的 obfs 是 { type, password } 对象
|
||||
|
||||
@@ -38,9 +38,21 @@ local function insecure_flag(n)
|
||||
end
|
||||
|
||||
-- 生成单节点分享链接;无法生成时返回 nil
|
||||
-- 分享链接里的 server 字面量。IPv6 必须加方括号:RFC 3986 的 authority 是
|
||||
-- `host:port`,而裸 IPv6 本身就含冒号,`fd00::1:443` 无法判断哪一段是端口。
|
||||
-- 本项目自己的解析器(util.split_hostport)就拆不开,重新导入得到 0 个节点;
|
||||
-- 规范的客户端同样会拒绝或拆错。output_wireguard_conf 的 Endpoint 一直是这么做的。
|
||||
local function host_literal(server)
|
||||
server = tostring(server or "")
|
||||
if server:find(":", 1, true) and server:sub(1, 1) ~= "[" then
|
||||
return "[" .. server .. "]"
|
||||
end
|
||||
return server
|
||||
end
|
||||
|
||||
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 server = host_literal(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()
|
||||
@@ -51,7 +63,15 @@ function M.to_share_uri(n)
|
||||
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
|
||||
-- SIP002:SS-URI = "ss://" userinfo "@" host ":" port [ "/" ] [ "?" plugin ] [ "#" tag ]
|
||||
-- 插件参数整体做一次百分号编码(`;` 与 `=` 在 query 里必须转义)。
|
||||
-- 不回写这一项时,带插件的节点导出后再导入就永久丢失插件配置。
|
||||
local query = ""
|
||||
if type(n.plugin) == "string" and n.plugin ~= "" then
|
||||
query = "/?plugin=" .. url_encode(n.plugin)
|
||||
end
|
||||
return "ss://" .. userinfo .. "@" .. server .. ":" .. tostring(port)
|
||||
.. query .. "#" .. name
|
||||
end
|
||||
|
||||
if proto == "vmess" then
|
||||
|
||||
@@ -12,19 +12,30 @@ local function build_stream_settings(n)
|
||||
ss.network = net
|
||||
|
||||
-- 传输层
|
||||
-- 空表必须用 util.JSON_EMPTY_OBJECT(编码成 {})而不是裸 {}:json_encode 的
|
||||
-- is_array 会把空表判成数组编成 [],而 Xray 用标准库 json.Unmarshal 解析配置,
|
||||
-- wsSettings / grpcSettings / httpSettings 在它那边是结构体指针,把数组解进
|
||||
-- 结构体会直接 UnmarshalTypeError,Xray 拒绝启动。触发条件很普通:grpc 节点
|
||||
-- 没填服务名、ws 节点既没 path 也没 host。
|
||||
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
|
||||
local ws = util.JSON_EMPTY_OBJECT
|
||||
if n.path or n.host then
|
||||
ws = {}
|
||||
if n.path then ws.path = n.path end
|
||||
if n.host then ws.headers = { Host = n.host } end
|
||||
end
|
||||
ss.wsSettings = ws
|
||||
elseif net == "grpc" then
|
||||
local g = {}
|
||||
if n.path then g.serviceName = n.path end
|
||||
local g = util.JSON_EMPTY_OBJECT
|
||||
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
|
||||
local h = util.JSON_EMPTY_OBJECT
|
||||
if n.host or n.path then
|
||||
h = {}
|
||||
if n.host then h.host = { n.host } end
|
||||
if n.path then h.path = n.path end
|
||||
end
|
||||
ss.httpSettings = h
|
||||
elseif net == "kcp" then
|
||||
ss.kcpSettings = { header = { type = n.headerType or "none" } }
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
|
||||
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")
|
||||
@@ -97,6 +96,98 @@ function M.detect(content)
|
||||
return "unknown"
|
||||
end
|
||||
|
||||
-- 「混合格式」提示里各格式的显示名
|
||||
local FORMAT_LABELS = {
|
||||
uri = "URI 链接",
|
||||
json = "JSON",
|
||||
yaml = "Clash YAML",
|
||||
["wireguard-conf"] = "WireGuard .conf",
|
||||
surge = "Surge/Loon 配置",
|
||||
}
|
||||
|
||||
-- 行首锚定,数出「整行就是一条节点链接」的行数。
|
||||
--
|
||||
-- 不能用 detect() 里那条宽松的 `content:find("://")`:Clash YAML 的
|
||||
-- `url: https://…`、sing-box JSON 的 `"url": "https://…"` 都含 "://",
|
||||
-- 拿它当 URI 判据的话,一份**完全正常**、只是带了个订阅地址的配置
|
||||
-- 会被判成「YAML + URI 混合」而拒绝导入 —— 误判比漏报更糟。
|
||||
-- 锚定行首之后,只有「一行就是一条链接」才算数,那正是 URI 订阅的形态。
|
||||
local function count_uri_lines(content)
|
||||
local n = 0
|
||||
for line in content:gmatch("[^\r\n]+") do
|
||||
local scheme = line:match("^%s*(%a[%w]*):/")
|
||||
if scheme and SUPPORTED[scheme:lower()] then n = n + 1 end
|
||||
end
|
||||
return n
|
||||
end
|
||||
|
||||
-- INI 段头:`[Interface]` / `[Peer]` / `[Proxy]` / `[server_local]`
|
||||
-- 它们同样以 "[" 开头,但**不是** JSON 数组。
|
||||
-- detect() 靠判断次序避开了这一点(surge 与 .conf 都排在 "[" 之前,先命中就返回),
|
||||
-- 而 detect_all 是「全部收集」,次序挡不住,必须显式排除 ——
|
||||
-- 否则一份完全正常的 Surge 配置会被判成「Surge + JSON 混合」而拒绝导入。
|
||||
local function is_ini_section_head(line)
|
||||
return line:match("^%s*%[%a[%w_%-%s]*%]%s*$") ~= nil
|
||||
end
|
||||
|
||||
-- 行首的 JSON 对象起始:`{` 单独一行,或 `{"key"…`。
|
||||
-- 对象的第一个键必然是带引号的字符串,因此 YAML 的流式映射 `{path: /x}`
|
||||
-- (未加引号的键)不会被误判成 JSON。
|
||||
local function has_json_object_line(content)
|
||||
for line in content:gmatch("[^\r\n]+") do
|
||||
if line:match("^%s*{%s*$") or line:match('^%s*{%s*"') then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- 行首的 JSON 数组起始,排除 INI 段头
|
||||
local function has_json_array_line(content)
|
||||
for line in content:gmatch("[^\r\n]+") do
|
||||
if line:match("^%s*%[") and not is_ini_section_head(line) then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- 内容**同时**命中的全部格式,按 detect() 的优先级排列。
|
||||
--
|
||||
-- 只服务于「混合格式」提示(见 M.parse_local 的文本模式):detect() 只返回
|
||||
-- 优先级最高的那一个,其余格式的内容会被**静默丢弃** —— 粘进「URI + WG conf」,
|
||||
-- 只导入到 WG 节点;粘进「URI + JSON」,只导入到 URI 节点。而 parse 返回的是
|
||||
-- 合法表,同步报成功,用户以为整份都导进来了。
|
||||
--
|
||||
-- 判据必须从严:这个返回值会被用来**拒绝导入**,误判会把一份正常配置挡在门外。
|
||||
-- 因此一律用「整份文档级标记 + 行首锚定」,宁可漏报,不可误报。
|
||||
function M.detect_all(content)
|
||||
content = util.trim(content or "")
|
||||
content = content:gsub("^\239\187\191", "")
|
||||
local out = {}
|
||||
if content == "" then return out end
|
||||
local lower = content:lower()
|
||||
|
||||
-- .conf 的双条件与 detect() 一致
|
||||
local wg_conf = lower:match("%[interface%]") and lower:match("privatekey%s*=")
|
||||
if wg_conf then out[#out + 1] = "wireguard-conf" end
|
||||
|
||||
if content:match("^[%s]*proxies:") or content:match("^[%s]*outbounds:") then
|
||||
out[#out + 1] = "yaml"
|
||||
else
|
||||
for line in content:gmatch("[^\r\n]+") do
|
||||
if line:match("^%s*proxies:%s*$") or line:match("^%s*outbounds:%s*$") then
|
||||
out[#out + 1] = "yaml"
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
if parser_surge.is_config(content) then out[#out + 1] = "surge" end
|
||||
if count_uri_lines(content) > 0 then out[#out + 1] = "uri" end
|
||||
-- JSON 排在最后:它的判据最宽(一份 JSON 配置里也可能出现 URI 行),
|
||||
-- 放前面不影响结果,但排最后读起来与 detect() 的优先级一致。
|
||||
if (has_json_object_line(content) or has_json_array_line(content)) and not wg_conf then
|
||||
out[#out + 1] = "json"
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- ---------- 协议解析 ----------
|
||||
local function parse_ss(body)
|
||||
-- ss:// 兼容多种变体:base64(method:password@host:port)、method:password@host:port、
|
||||
@@ -1087,6 +1178,42 @@ local function parse_yaml_content(content)
|
||||
end
|
||||
end
|
||||
end
|
||||
-- sing-box 的 transport 同样是嵌套 map:{type, path, headers.Host,
|
||||
-- service_name, host}。字段名对照 parser_json_config.parse_singbox_json
|
||||
-- (本项目读取 sing-box 出站的权威实现),不另立一套。
|
||||
-- 不展开的话 net 只会取到 n.net / n.network —— sing-box 出站里这两个
|
||||
-- 字段都不存在,ws / grpc 节点会全部按 tcp 导入,客户端拿明文 tcp 去连
|
||||
-- 只开了 ws 的端口,握手必然失败且不报错。
|
||||
local transport_map = type(n.transport) == "table" and n.transport or nil
|
||||
local t_net, t_path, t_host
|
||||
if transport_map then
|
||||
local tt = transport_map.type
|
||||
if tt == "ws" then
|
||||
t_net = "ws"
|
||||
t_path = transport_map.path
|
||||
if type(transport_map.headers) == "table" then
|
||||
t_host = transport_map.headers.Host or transport_map.headers.host
|
||||
end
|
||||
elseif tt == "grpc" then
|
||||
t_net = "grpc"
|
||||
t_path = transport_map.service_name
|
||||
elseif tt == "http" then
|
||||
t_net = "http"
|
||||
t_path = transport_map.path
|
||||
-- http 的 host 是数组,取首个;httpupgrade 的是单个字符串
|
||||
if type(transport_map.host) == "table" then
|
||||
t_host = transport_map.host[1]
|
||||
elseif type(transport_map.host) == "string" and transport_map.host ~= "" then
|
||||
t_host = transport_map.host
|
||||
end
|
||||
elseif tt == "httpupgrade" then
|
||||
t_net = "http"
|
||||
t_path = transport_map.path
|
||||
if type(transport_map.host) == "string" and transport_map.host ~= "" then
|
||||
t_host = transport_map.host
|
||||
end
|
||||
end
|
||||
end
|
||||
-- 注意不能用 `(cond) and nil or x` 写法:Lua 的 and/or 在 cond 为真时
|
||||
-- 结果是 nil,会被后面的 or 继续兜底,等于没生效
|
||||
local tls_security = n.security
|
||||
@@ -1127,7 +1254,12 @@ local function parse_yaml_content(content)
|
||||
-- 行解析器读出来的是字符串,而 "false" / "0" 在 Lua 里也是真值,
|
||||
-- 直接透传会让 skip-cert-verify=false 变成「跳过证书校验」,故显式判假。
|
||||
["skip-cert-verify"] = skip_cert_verify,
|
||||
net = n.net or n.network,
|
||||
-- transport 展开的传输层优先:它才是 sing-box 的权威写法
|
||||
net = t_net or n.net or n.network,
|
||||
-- 简易解析器此前**完全没有**把 path / host 带进 node_data,
|
||||
-- 于是走这条回退路径的 ws / grpc 节点连 path 与 Host 都丢了
|
||||
path = t_path or n.path,
|
||||
host = t_host or n.host,
|
||||
alterId = tonumber(n.alterId),
|
||||
}
|
||||
result[#result + 1] = node.normalize(node_data)
|
||||
@@ -1322,6 +1454,23 @@ function M.parse_local_link(url)
|
||||
end
|
||||
|
||||
-- 解析订阅内容,返回 { nodes = {...}, format = "..." } 或 nil, err
|
||||
-- 统一兜底:丢弃残缺节点(缺 server,或端口为空 / 不在 1–65535)。
|
||||
--
|
||||
-- 这类节点会被写成客户端加载不了的配置,而 mihomo / sing-box / Xray 都是
|
||||
-- 「一个坏节点废掉整份文件」——用户看到的是整个订阅不可用,而不是少一个节点。
|
||||
-- URI 路径一直在各解析器内部做这个校验(valid_hostport),Clash YAML /
|
||||
-- sing-box JSON / Surge 路径漏了:一份 `port: 1e999` 的 YAML 能产出 port=inf
|
||||
-- 的节点,落盘读回来又是 nil。各解析器只管把字段填对,校验统一放在这里。
|
||||
local function finish(nodes, format)
|
||||
local out = {}
|
||||
for _, n in ipairs(nodes or {}) do
|
||||
if type(n) == "table" and valid_hostport(n.server, n.port) then
|
||||
out[#out + 1] = n
|
||||
end
|
||||
end
|
||||
return { nodes = out, format = format }
|
||||
end
|
||||
|
||||
function M.parse(content)
|
||||
if not content or content == "" then return { nodes = {}, format = "empty" } end
|
||||
local format = M.detect(content)
|
||||
@@ -1332,7 +1481,7 @@ function M.parse(content)
|
||||
-- 不是格式不认识。与空串保持同一返回形态(0 节点、格式 empty)。
|
||||
return { nodes = {}, format = "empty" }
|
||||
elseif format == "uri" then
|
||||
return { nodes = parse_lines(split_lines(content)), format = "uri" }
|
||||
return finish(parse_lines(split_lines(content)), "uri")
|
||||
elseif format == "base64" then
|
||||
-- 兼容标准 base64 与 base64url(- _ 无 padding):base64_url_decode 两者皆可
|
||||
local decoded = util.base64_url_decode(content)
|
||||
@@ -1348,21 +1497,21 @@ function M.parse(content)
|
||||
if res then res.format = "base64" end
|
||||
return res, err
|
||||
end
|
||||
return { nodes = parse_lines(split_lines(decoded)), format = "base64" }
|
||||
return finish(parse_lines(split_lines(decoded)), "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" }
|
||||
return finish(nodes, "json")
|
||||
elseif format == "yaml" then
|
||||
local nodes = M.parse_yaml(content)
|
||||
return { nodes = nodes, format = "yaml" }
|
||||
return finish(nodes, "yaml")
|
||||
elseif format == "surge" then
|
||||
local nodes = parser_surge.parse(content)
|
||||
return { nodes = nodes, format = "surge" }
|
||||
return finish(nodes, "surge")
|
||||
elseif format == "wireguard-conf" then
|
||||
local nodes, err = parse_wireguard_conf(content)
|
||||
if not nodes then return nil, err end
|
||||
return { nodes = nodes, format = "wireguard-conf" }
|
||||
return finish(nodes, "wireguard-conf")
|
||||
end
|
||||
return nil, "无法识别的订阅格式"
|
||||
end
|
||||
@@ -1454,7 +1603,20 @@ function M.parse_local(content, mode)
|
||||
end
|
||||
return { nodes = nodes, format = "local-form" }
|
||||
end
|
||||
-- 文本模式:使用通用解析
|
||||
-- 文本模式:使用通用解析。
|
||||
--
|
||||
-- 但先挡住「一份文本里混了多种格式」:detect() 只认优先级最高的那一种,
|
||||
-- 其余部分被静默丢弃,而 parse 返回的是合法表 —— 同步报成功,
|
||||
-- 用户以为整份都导进来了(实测:「URI + WG conf」只剩 WG 节点,
|
||||
-- 「URI + JSON」只剩 URI 节点,err 均为 nil)。
|
||||
-- 一次只导入一种格式,混用就明确报错,而不是默默少一半节点。
|
||||
local formats = M.detect_all(content)
|
||||
if #formats > 1 then
|
||||
local names = {}
|
||||
for i, f in ipairs(formats) do names[i] = FORMAT_LABELS[f] or f end
|
||||
return nil, "同一份文本里混用了多种格式(" .. table.concat(names, " + ") ..
|
||||
"):一次只能导入一种格式,请拆开后分次导入"
|
||||
end
|
||||
return M.parse(content)
|
||||
end
|
||||
|
||||
|
||||
@@ -119,6 +119,11 @@ function M.parse_singbox_json(content)
|
||||
if outbound.tls.insecure ~= nil then
|
||||
node_data["skip-cert-verify"] = outbound.tls.insecure
|
||||
end
|
||||
-- uTLS 指纹。简易 YAML 侧早就读了这个字段,JSON 侧一直漏着,
|
||||
-- 同一条订阅走两条导入路径会得到不同的节点。
|
||||
if type(outbound.tls.utls) == "table" and outbound.tls.utls.fingerprint then
|
||||
node_data.fp = outbound.tls.utls.fingerprint
|
||||
end
|
||||
end
|
||||
|
||||
-- sing-box 只有 vmess 出站有 security 字段,且它是加密方式
|
||||
@@ -130,6 +135,45 @@ function M.parse_singbox_json(content)
|
||||
node_data.net = outbound.network
|
||||
end
|
||||
|
||||
-- sing-box 用 transport 对象表达传输层,字段名与 v2ray 的
|
||||
-- streamSettings 完全不同(上游 configuration/shared/v2ray-transport/):
|
||||
-- ws path、headers.Host
|
||||
-- grpc service_name
|
||||
-- http path、host(**数组**)
|
||||
-- httpupgrade path、host(**单个字符串**)
|
||||
-- quic 本项目模型没有该传输方式,不猜映射
|
||||
-- 只读 outbound.network 是不够的:sing-box 出站里根本没有这个字段,
|
||||
-- 于是 ws / grpc / h2 节点全部按 tcp 导入 —— 客户端拿明文 tcp 去连
|
||||
-- 只开了 ws 的端口,握手必然失败且不报错。这类节点在机场导出的
|
||||
-- sing-box 配置里占比很高。
|
||||
if type(outbound.transport) == "table" then
|
||||
local t = outbound.transport
|
||||
if t.type == "ws" then
|
||||
node_data.net = "ws"
|
||||
if t.path then node_data.path = t.path end
|
||||
if type(t.headers) == "table" then
|
||||
node_data.host = t.headers.Host or t.headers.host
|
||||
end
|
||||
elseif t.type == "grpc" then
|
||||
node_data.net = "grpc"
|
||||
if t.service_name then node_data.path = t.service_name end
|
||||
elseif t.type == "http" then
|
||||
node_data.net = "http"
|
||||
if t.path then node_data.path = t.path end
|
||||
if type(t.host) == "table" then
|
||||
if t.host[1] then node_data.host = t.host[1] end
|
||||
elseif type(t.host) == "string" and t.host ~= "" then
|
||||
node_data.host = t.host
|
||||
end
|
||||
elseif t.type == "httpupgrade" then
|
||||
node_data.net = "http"
|
||||
if t.path then node_data.path = t.path end
|
||||
if type(t.host) == "string" and t.host ~= "" then
|
||||
node_data.host = t.host
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
nodes[#nodes + 1] = node.normalize(node_data)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,187 +0,0 @@
|
||||
-- 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
|
||||
@@ -194,6 +194,64 @@ function M.split_hostport(s)
|
||||
end
|
||||
end
|
||||
|
||||
-- 解析 SIP003 插件串(shadowsocks 的 `plugin` 字段)。
|
||||
--
|
||||
-- 格式出自 SIP002 §"For plugin argument",与 SIP003 的 SS_PLUGIN_OPTIONS 同构:
|
||||
-- <name>[;<key>=<value>]... 例:obfs-local;obfs=http;obfs-host=www.baidu.com
|
||||
-- 值里的 `;`、`=`、`\` 按规范用反斜杠转义,因此切分必须跳过转义序列 ——
|
||||
-- 否则 `path=/a\;b` 会被切成两段,得到一个并不存在的选项,而 `\;` 是 v2ray-plugin
|
||||
-- 的 path 里完全可能出现的写法。
|
||||
--
|
||||
-- 返回 name, opts_string, opts_map:
|
||||
-- * opts_string 是 `;` 之后的原样串(含转义),用于 sing-box 的 plugin_opts ——
|
||||
-- 该字段就是交给插件进程的 SS_PLUGIN_OPTIONS,原样透传才不失真;
|
||||
-- * opts_map 是拆好的键值对,用于需要逐键映射的目标(mihomo 的 plugin-opts)。
|
||||
-- 不带 `=` 的裸标志(v2ray-plugin 的 `tls`)取值为 true。
|
||||
-- 空串或没有插件名时返回 nil。
|
||||
function M.parse_sip003_plugin(raw)
|
||||
if type(raw) ~= "string" or raw == "" then return nil end
|
||||
local fields, buf = {}, {}
|
||||
local i, n = 1, #raw
|
||||
-- 必须是 while:下面遇到转义序列要一次吃掉两个字符,Lua 的数值 for 会在每轮
|
||||
-- 重新赋值控制变量,循环体内改 i 不生效。
|
||||
while i <= n do
|
||||
local c = raw:sub(i, i)
|
||||
if c == "\\" and i < n then
|
||||
buf[#buf + 1] = c .. raw:sub(i + 1, i + 1)
|
||||
i = i + 2
|
||||
elseif c == ";" then
|
||||
fields[#fields + 1] = table.concat(buf)
|
||||
buf = {}
|
||||
i = i + 1
|
||||
else
|
||||
buf[#buf + 1] = c
|
||||
i = i + 1
|
||||
end
|
||||
end
|
||||
fields[#fields + 1] = table.concat(buf)
|
||||
|
||||
local name = M.trim(fields[1] or "")
|
||||
if name == "" then return nil end
|
||||
|
||||
local opts_list = {}
|
||||
for idx = 2, #fields do
|
||||
local f = M.trim(fields[idx])
|
||||
if f ~= "" then opts_list[#opts_list + 1] = f end
|
||||
end
|
||||
|
||||
local map = {}
|
||||
for _, f in ipairs(opts_list) do
|
||||
local k, v = f:match("^([^=]+)=(.*)$")
|
||||
if k then
|
||||
map[M.trim(k)] = v
|
||||
else
|
||||
map[f] = true
|
||||
end
|
||||
end
|
||||
|
||||
return name, table.concat(opts_list, ";"), map
|
||||
end
|
||||
|
||||
-- ---------- JSON ----------
|
||||
local function utf8_char(cp)
|
||||
if cp < 0x80 then
|
||||
@@ -225,7 +283,14 @@ local JSON_NULL = {}
|
||||
-- JSON 空对象占位符。Lua 的空表无法区分 {} 与 [](is_array 判定空表为数组),
|
||||
-- 而 sing-box / Xray 的部分字段必须是对象("tls": {}、"settings": {}),
|
||||
-- 编码成 [] 会被客户端拒绝。需要空对象时显式使用本常量。
|
||||
M.JSON_EMPTY_OBJECT = setmetatable({}, { __tostring = function() return "{}" end })
|
||||
--
|
||||
-- 标记用的是**元表**而不是表本身的同一性:解码器每遇到一个 `{}` 必须返回一张
|
||||
-- 新表,不能返回这张共享的常量表。core.load 拿到 items 后会直接往里塞订阅
|
||||
-- (`items[id] = {...}`),共享表会让这次写入落到全局哨兵上,随后 json_encode
|
||||
-- 又把哨兵短路成 `{}` —— 结果是「接口返回新增成功、进程内 list 也看得到,
|
||||
-- 但文件里什么都没写,重启即消失」。
|
||||
local JSON_OBJ_MT = { __tostring = function() return "{}" end }
|
||||
M.JSON_EMPTY_OBJECT = setmetatable({}, JSON_OBJ_MT)
|
||||
|
||||
function M.json_encode(v)
|
||||
local function enc(v)
|
||||
@@ -244,12 +309,22 @@ function M.json_encode(v)
|
||||
else return string.format("\\u%04x", c:byte()) end
|
||||
end) .. '"'
|
||||
elseif t == "number" then
|
||||
if v ~= v then return "null" end
|
||||
-- 非有限数必须编成 null:`%.14g` 对 inf / -inf 产出的是字面量
|
||||
-- `inf` / `-inf`,那不是合法 JSON —— 整份文件会因此解析不了。
|
||||
-- 触发路径是真实存在的:订阅响应头 `subscription-userinfo` 里的
|
||||
-- `total=1e999` 经 tonumber 就是 inf,写进 subscriptions.json 后
|
||||
-- 下一次 load() 直接判「列表文件已损坏」,全部订阅从界面上消失;
|
||||
-- 节点侧的 port 同理,一个 inf 会毁掉该订阅的整个节点文件。
|
||||
if v ~= v or v == math.huge or v == -math.huge then return "null" end
|
||||
return string.format("%.14g", v)
|
||||
elseif t == "boolean" then
|
||||
return v and "true" or "false"
|
||||
elseif t == "table" then
|
||||
if v == M.JSON_EMPTY_OBJECT then
|
||||
-- 带空对象标记、且**确实还是空的**才编成 {}:标记只是「这里曾经是
|
||||
-- 空对象」的线索,调用方完全可能拿到解码结果后往里加键
|
||||
-- (core.load 就是这么用 items 的)。少了 next(v) == nil 这一半,
|
||||
-- 加过键的表会被编回 {},内容静默丢失。
|
||||
if getmetatable(v) == JSON_OBJ_MT and next(v) == nil then
|
||||
return "{}"
|
||||
elseif v == JSON_NULL then
|
||||
-- 数组里的 null 解码时被换成 JSON_NULL 占位(见 json_decode),
|
||||
@@ -286,11 +361,24 @@ function M.json_decode(s)
|
||||
end
|
||||
end
|
||||
|
||||
local function parse()
|
||||
-- 嵌套深度上限。parse 是递归下降,每进一层容器就多一层 Lua 调用栈;
|
||||
-- 几千层时 Lua 5.1 会**抛出** "stack overflow" 而不是返回错误,而本函数的
|
||||
-- 契约是 `nil, err`。抛出的异常会一路冒到调用方:core.add_local 里的
|
||||
-- `M.sync(id)` 没有 pcall,控制器也没兜住,于是本地订阅导入在订阅行已经
|
||||
-- 写盘之后变成 HTTP 500,且 sync 内的 save_meta(error=...) 全被跳过,
|
||||
-- 留下一条「看起来还在更新中」的空记录。
|
||||
-- 64 层远超任何真实配置(订阅节点是扁平的,sing-box 配置最深也不过十几层)。
|
||||
local MAX_DEPTH = 64
|
||||
|
||||
local function parse(depth)
|
||||
skip_ws()
|
||||
if i > len then return nil, "unexpected end" end
|
||||
local c = s:sub(i, i)
|
||||
|
||||
if (c == "{" or c == "[") and depth >= MAX_DEPTH then
|
||||
return nil, "nesting too deep"
|
||||
end
|
||||
|
||||
if c == "{" then
|
||||
i = i + 1
|
||||
local obj = {}
|
||||
@@ -299,16 +387,18 @@ function M.json_decode(s)
|
||||
-- {} 与 [],裸 {} 会被 is_array 判成数组、编码回 [],于是
|
||||
-- `{"tls":{}}` 往返变成 `{"tls":[]}` —— sing-box / Xray 里
|
||||
-- 要求是对象的字段(tls / settings)会因此被客户端拒绝。
|
||||
if s:sub(i, i) == "}" then i = i + 1 return M.JSON_EMPTY_OBJECT end
|
||||
-- 每次返回**新表**(带同一标记元表),不能返回共享常量:
|
||||
-- 见 JSON_OBJ_MT 处的说明。
|
||||
if s:sub(i, i) == "}" then i = i + 1 return setmetatable({}, JSON_OBJ_MT) end
|
||||
while true do
|
||||
skip_ws()
|
||||
local k, ke = parse()
|
||||
local k, ke = parse(depth + 1)
|
||||
if ke then return nil, ke end
|
||||
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, verr = parse()
|
||||
local val, verr = parse(depth + 1)
|
||||
if verr then return nil, verr end
|
||||
obj[k] = val
|
||||
skip_ws()
|
||||
@@ -329,7 +419,7 @@ function M.json_decode(s)
|
||||
skip_ws()
|
||||
if s:sub(i, i) == "]" then i = i + 1 return arr end
|
||||
while true do
|
||||
local val, verr = parse()
|
||||
local val, verr = parse(depth + 1)
|
||||
-- 必须把内层错误透出去:此前无条件把 nil 当成 null 占位,
|
||||
-- 于是 `[1,]` 这种畸形输入被静默接受(parse 报错后 i 未前进,
|
||||
-- 下一轮读到 `]` 就当成数组结束),解出 `{1, JSON_NULL}`。
|
||||
@@ -407,7 +497,7 @@ function M.json_decode(s)
|
||||
end
|
||||
end
|
||||
|
||||
local v, perr = parse()
|
||||
local v, perr = parse(0)
|
||||
-- 此前写成 `local v = parse(); return v` —— parse 的第二返回值(错误串)
|
||||
-- 被直接丢弃,于是 json_decode **永远不返回错误**:所有调用方的
|
||||
-- `local data, err = util.json_decode(...)` 里 err 判断都是死代码,
|
||||
@@ -492,6 +582,62 @@ function M.ensure_dir(path, mode)
|
||||
end
|
||||
end
|
||||
|
||||
-- ---------- 互斥锁(mkdir 锁 + 陈旧锁回收) ----------
|
||||
--
|
||||
-- 用途:把「读整表 → 改 → 写整表」这段临界区串行化。LuCI 页面保存订阅、
|
||||
-- cron 定时更新、组合订阅自动重算三者可能同时发生,后写者整表覆盖先写者,
|
||||
-- 结果是订阅静默丢失。本机没有 nixio,用不了 flock;Lua 5.1 的 io.open 也没有
|
||||
-- O_EXCL —— 但 `mkdir` 本身是原子的,成功者只有一个,拿它当锁。
|
||||
--
|
||||
-- 崩溃残留:持有者被杀掉时目录会留在原地,后来者必须能回收,否则永久死锁
|
||||
-- (比丢数据更糟:整台设备的订阅从此无法再修改)。判据是**锁目录自身的
|
||||
-- mtime**,不是目录里的文件 —— mtime 由 mkdir 原子地设好,不存在「目录已建、
|
||||
-- 时间戳还没写」的窗口;写在目录里的持有者文件只作排障用,不参与判定。
|
||||
M.LOCK_STALE = 60 -- 秒。超过这个时长仍未释放即视为持有者已死。
|
||||
|
||||
-- 锁目录是否已陈旧。用一条 shell 判断完成,不解析 stat 输出(少一处格式依赖)。
|
||||
-- stat 失败时回退成 0,于是「距今」必然大于阈值 —— 判定为陈旧,可以回收。
|
||||
-- 这是有意的失败方向:无法确认的锁宁可回收,也不要永久卡死。
|
||||
local function lock_is_stale(path, stale)
|
||||
local cmd = string.format(
|
||||
"[ $(( $(date +%%s) - $(stat -c %%Y %s 2>/dev/null || echo 0) )) -gt %d ]",
|
||||
M.shq(path), stale)
|
||||
return os.execute(cmd) == 0
|
||||
end
|
||||
|
||||
-- 取锁。成功返回 true,调用方**必须**在临界区结束时调 lock_release。
|
||||
-- 失败返回 false(别人正持有,且未判定为陈旧)。
|
||||
-- opts.stale 覆盖超时阈值;opts.pid 写入持有者文件(默认 os.getpid 不可用,留 0)。
|
||||
function M.lock_acquire(path, opts)
|
||||
opts = opts or {}
|
||||
local stale = tonumber(opts.stale) or M.LOCK_STALE
|
||||
-- 最多两轮:第一轮抢不到就查一次陈旧性,回收后第二轮重抢。
|
||||
for _ = 1, 2 do
|
||||
if os.execute("mkdir " .. M.shq(path) .. " >/dev/null 2>&1") == 0 then
|
||||
-- 持有者信息纯属排障用(`cat` 一眼看出是谁、什么时候拿的)。
|
||||
-- 写失败不影响正确性,因此不检查返回值。
|
||||
local f = io.open(path .. "/owner", "wb")
|
||||
if f then
|
||||
f:write(string.format("%d %d\n", tonumber(opts.pid) or 0, os.time()))
|
||||
f:close()
|
||||
end
|
||||
return true
|
||||
end
|
||||
if lock_is_stale(path, stale) then
|
||||
os.execute("rm -rf " .. M.shq(path) .. " >/dev/null 2>&1")
|
||||
else
|
||||
return false
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- 释放锁。持有者之外的人不应调用;这里不做持有者校验 —— 校验需要额外的
|
||||
-- 竞态窗口,而收益只是把「用错 API」变成一条错误信息。
|
||||
function M.lock_release(path)
|
||||
os.execute("rm -rf " .. M.shq(path) .. " >/dev/null 2>&1")
|
||||
end
|
||||
|
||||
-- ---------- 人性化格式 ----------
|
||||
-- 字节数 → 可读字符串(B/K/M/G/T),如 20G、512M
|
||||
function M.human_bytes(n)
|
||||
|
||||
Reference in new issue
Block a user