mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-05 18:44:03 +08:00
💐 Sync 2026-10-06 12:33:44
This commit is contained in:
1 parent
8be03ce939
commit
7fe69dbb3a
246 files changed
+50330
-3414
No files matched your search
@@ -12,13 +12,22 @@ module("luci.controller.admin.substore", package.seeall)
|
||||
local i18n = require("luci.i18n")
|
||||
local function _(s) return i18n.translate(s) end
|
||||
|
||||
-- 后端(substore.* 模块)返回的是**语言中立的英文 msgid** —— 它们不能
|
||||
-- require("luci.i18n"),因为 substore-cron.sh 会在没有 LuCI 环境的独立 lua
|
||||
-- 进程里跑 core.sync。翻译在这里做:控制器是 ?err= 这条回显链路的边界。
|
||||
--
|
||||
-- msg.translate 对**已经翻译过**的串是幂等的(见 msg.lua),所以下面那些
|
||||
-- `err or _("…")` 的 fallback 传进来也不会被二次翻译。
|
||||
local msg = require("substore.msg")
|
||||
local function tmsg(s) return msg.translate(s, _) end
|
||||
|
||||
-- 返回列表页。err 非空时把错误带到列表页显示(§18:失败必须让用户看见,
|
||||
-- 不能「失败了却看起来像成功」)。沿用 LuCI 既有的 query + 模板渲染,不引入新 framework。
|
||||
local function back_to_list(err)
|
||||
local http = require("luci.http")
|
||||
local url = luci.dispatcher.build_url("admin", "services", "substore", "list")
|
||||
if err ~= nil and tostring(err) ~= "" then
|
||||
url = url .. "?err=" .. luci.util.urlencode(tostring(err))
|
||||
url = url .. "?err=" .. luci.util.urlencode(tmsg(err))
|
||||
end
|
||||
http.redirect(url)
|
||||
end
|
||||
@@ -348,7 +357,7 @@ local function back_to_nodes(http, err)
|
||||
if v and v ~= "" then qs = qs .. "&" .. k .. "=" .. luci.util.urlencode(v) end
|
||||
end
|
||||
if err ~= nil and tostring(err) ~= "" then
|
||||
qs = qs .. "&err=" .. luci.util.urlencode(tostring(err))
|
||||
qs = qs .. "&err=" .. luci.util.urlencode(tmsg(err))
|
||||
end
|
||||
http.redirect(luci.dispatcher.build_url("admin", "services", "substore", "nodes") .. qs)
|
||||
end
|
||||
@@ -518,7 +527,10 @@ function action_update()
|
||||
core.save_meta(id, { error = tostring(res), last_update = os.time() })
|
||||
elseif not res then
|
||||
-- 正常返回但失败:第三个返回值是错误信息
|
||||
core.save_meta(id, { error = tostring(err or _("Update failed")), last_update = os.time() })
|
||||
-- 存进 meta.error 的必须是**语言中立的 msgid**(不是 _() 的结果):
|
||||
-- 它会一直留在列表文件里,而读它的页面未必是同一个语言环境。
|
||||
-- 翻译交给读的那一端(视图里的 msg.translate)。
|
||||
core.save_meta(id, { error = tostring(err or "Update failed"), last_update = os.time() })
|
||||
end
|
||||
end
|
||||
back_to_list(post_fail_msg)
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
<%
|
||||
local http = require("luci.http")
|
||||
local core = require("substore.core")
|
||||
-- meta.error / human_duration 返回的都是语言中立的 msgid(可能是组合消息),
|
||||
-- 翻译在视图侧做 —— 后端模块不能 require("luci.i18n"),见 msg.lua 的文件头。
|
||||
local msg = require("substore.msg")
|
||||
local function tmsg(s) return msg.translate(s, luci.i18n.translate) end
|
||||
local id = http.formvalue("id") or ""
|
||||
local it = id ~= "" and core.get(id) or nil
|
||||
local is_edit = it ~= nil
|
||||
@@ -36,7 +40,7 @@
|
||||
traffic_str = util.human_bytes(rem)
|
||||
end
|
||||
if it.expire then
|
||||
time_str = util.human_duration((tonumber(it.expire) or 0) - os.time())
|
||||
time_str = tmsg(util.human_duration((tonumber(it.expire) or 0) - os.time()))
|
||||
end
|
||||
end
|
||||
%>
|
||||
@@ -149,7 +153,7 @@
|
||||
<% if traffic_str then %> | <%:Remaining traffic:%> <%=traffic_str%><% end %>
|
||||
<% if time_str then %> | <%:Remaining time:%> <%=time_str%><% end %>
|
||||
| <%:Last updated:%> <%=os.date("%Y-%m-%d %H:%M", it.last_update)%>
|
||||
<% if it.error and it.error ~= "" then %> | <span style="color:#cc0000"><%=luci.util.pcdata(it.error)%></span><% end %>
|
||||
<% if it.error and it.error ~= "" then %> | <span style="color:#cc0000"><%=luci.util.pcdata(tmsg(it.error))%></span><% end %>
|
||||
</span>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
<%
|
||||
local http = require("luci.http")
|
||||
local core = require("substore.core")
|
||||
-- meta.error 是语言中立的 msgid(可能是组合消息),翻译在视图侧做 ——
|
||||
-- 后端模块不能 require("luci.i18n"),见 msg.lua 的文件头。
|
||||
local msg = require("substore.msg")
|
||||
local function tmsg(s) return msg.translate(s, luci.i18n.translate) end
|
||||
local id = http.formvalue("id") or ""
|
||||
local it = id ~= "" and core.get(id) or nil
|
||||
local is_edit = it ~= nil
|
||||
@@ -113,7 +117,7 @@
|
||||
<span style="color:#808080">
|
||||
<%:Nodes:%> <%=it.node_count or 0%>
|
||||
| <%:Last updated:%> <%=os.date("%Y-%m-%d %H:%M", it.last_update)%>
|
||||
<% if it.error and it.error ~= "" then %> | <span style="color:#cc0000"><%=luci.util.pcdata(it.error)%></span><% end %>
|
||||
<% if it.error and it.error ~= "" then %> | <span style="color:#cc0000"><%=luci.util.pcdata(tmsg(it.error))%></span><% end %>
|
||||
</span>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
local core = require("substore.core")
|
||||
local output = require("substore.output")
|
||||
local util = require("substore.util")
|
||||
-- output.generate 的 err 是语言中立的 msgid(可能是组合消息),翻译在视图侧做 ——
|
||||
-- 后端模块不能 require("luci.i18n"),见 msg.lua 的文件头。
|
||||
local msg = require("substore.msg")
|
||||
local function tmsg(s) return msg.translate(s, luci.i18n.translate) end
|
||||
local ctx = luci.dispatcher.context or {}
|
||||
local q = ctx.query or {}
|
||||
local id = http.formvalue("id") or q.id or ""
|
||||
@@ -41,7 +45,7 @@
|
||||
</form>
|
||||
|
||||
<% if err then %>
|
||||
<div style="color:#cc0000"><%=luci.util.pcdata(err)%></div>
|
||||
<div style="color:#cc0000"><%=luci.util.pcdata(tmsg(err))%></div>
|
||||
<% else %>
|
||||
<textarea id="out_text" readonly style="width:100%;height:300px;font-family:monospace"><%=luci.util.pcdata(out)%></textarea>
|
||||
<div style="margin-top:0.5em">
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<%
|
||||
-- §18:控制器把失败原因放在 ?err= 上带回来,这里渲染成可见的提示。
|
||||
-- pcdata 转义:err 可能包含用户提交的内容,不能直接当 HTML 输出。
|
||||
-- 控制器已经翻译过 flash_err(它是 ?err= 这条链路的边界),这里不再翻。
|
||||
local flash_err = require("luci.http").formvalue("err")
|
||||
if flash_err and flash_err ~= "" then
|
||||
%>
|
||||
@@ -48,6 +49,10 @@
|
||||
<%
|
||||
local core = require("substore.core")
|
||||
local output = require("substore.output")
|
||||
-- meta.error 是**语言中立的 msgid**(可能还是 msg.compose 拼出来的组合消息),
|
||||
-- 翻译在这里做 —— 后端模块不能 require("luci.i18n"),见 msg.lua 的文件头。
|
||||
local msg = require("substore.msg")
|
||||
local function tmsg(s) return msg.translate(s, luci.i18n.translate) end
|
||||
local http_env = require("luci.http")
|
||||
local items = core.list()
|
||||
local base = luci.dispatcher.build_url("substore", "download")
|
||||
@@ -63,7 +68,7 @@
|
||||
local status_text = luci.i18n.translate("Pending")
|
||||
if it.error and it.error ~= "" then
|
||||
status_color = "#cc0000"
|
||||
status_text = it.error
|
||||
status_text = tmsg(it.error)
|
||||
elseif it.node_count and it.node_count > 0 then
|
||||
status_color = "#006600"
|
||||
status_text = luci.i18n.translate("OK")
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
local util = require("substore.util")
|
||||
local http = require("substore.http")
|
||||
local parser = require("substore.parser")
|
||||
local msg = require("substore.msg")
|
||||
|
||||
local M = {}
|
||||
|
||||
@@ -65,7 +66,7 @@ function M.resolve_user_agent(preset, custom)
|
||||
if ua == "" then return "" end
|
||||
else
|
||||
ua = UA_BY_KEY[preset]
|
||||
if not ua then return nil, "未知的订阅客户端类型: " .. preset end
|
||||
if not ua then return nil, msg.join("Unknown subscription client type: ", preset) end
|
||||
end
|
||||
return http.validate_user_agent(ua)
|
||||
end
|
||||
@@ -114,7 +115,7 @@ local function load()
|
||||
if not raw or raw == "" then return 0, {} end
|
||||
local data = util.json_decode(raw)
|
||||
if type(data) ~= "table" then
|
||||
return 0, {}, "订阅列表文件已损坏,无法解析:" .. M.LIST_FILE
|
||||
return 0, {}, msg.join("Subscription list file is corrupted, cannot parse: ", M.LIST_FILE)
|
||||
end
|
||||
local seq = tonumber(data._seq) or 0
|
||||
local items = type(data.items) == "table" and data.items or {}
|
||||
@@ -134,7 +135,7 @@ local function load()
|
||||
if type(meta) == "table" then clean[id] = meta else bad = true end
|
||||
end
|
||||
if bad then
|
||||
return seq, clean, "订阅列表文件已损坏,存在非法条目:" .. M.LIST_FILE
|
||||
return seq, clean, msg.join("Subscription list file is corrupted, contains an invalid entry: ", M.LIST_FILE)
|
||||
end
|
||||
return seq, items
|
||||
end
|
||||
@@ -168,7 +169,7 @@ local function with_list_lock(f)
|
||||
end
|
||||
local lock_dir = M.DATA_DIR .. "/.lock"
|
||||
if not util.lock_acquire(lock_dir, { stale = M.LOCK_STALE }) then
|
||||
return nil, "订阅列表正被另一个进程修改,请稍后重试"
|
||||
return nil, "Subscription list is being modified by another process, please retry later"
|
||||
end
|
||||
lock_depth = 1
|
||||
local a, b = f()
|
||||
@@ -213,7 +214,7 @@ function M.add(name, url, opts)
|
||||
name = util.trim(name or "")
|
||||
url = util.trim(url or "")
|
||||
opts = opts or {}
|
||||
if name == "" or url == "" then return nil, "名称/URL 不能为空" end
|
||||
if name == "" or url == "" then return nil, "Name/URL must not be empty" end
|
||||
local seq, items, lerr = load()
|
||||
if lerr then return nil, lerr end
|
||||
seq = seq + 1
|
||||
@@ -239,7 +240,7 @@ function M.add(name, url, opts)
|
||||
raw_content = "",
|
||||
local_mode = "text",
|
||||
}
|
||||
if not save(seq, items) then return nil, "写入失败" end
|
||||
if not save(seq, items) then return nil, "Write failed" end
|
||||
return id
|
||||
end
|
||||
|
||||
@@ -248,7 +249,7 @@ function M.add_local(name, raw_content, local_mode, opts)
|
||||
raw_content = util.trim(raw_content or "")
|
||||
local_mode = local_mode or "text"
|
||||
opts = opts or {}
|
||||
if name == "" or raw_content == "" then return nil, "名称/内容 不能为空" end
|
||||
if name == "" or raw_content == "" then return nil, "Name/Content must not be empty" end
|
||||
local seq, items, lerr = load()
|
||||
if lerr then return nil, lerr end
|
||||
seq = seq + 1
|
||||
@@ -269,7 +270,7 @@ function M.add_local(name, raw_content, local_mode, opts)
|
||||
raw_content = raw_content,
|
||||
local_mode = local_mode,
|
||||
}
|
||||
if not save(seq, items) then return nil, "写入失败" end
|
||||
if not save(seq, items) then return nil, "Write failed" end
|
||||
-- 立即解析一次
|
||||
M.sync(id)
|
||||
return id
|
||||
@@ -289,7 +290,7 @@ function M.ensure_token(id)
|
||||
-- 一个必然报「无效的订阅 token」的链接;而且内存表用完即弃,
|
||||
-- 下一次调用会再生成一个**不同**的 token,链接还会跳来跳去。
|
||||
-- 失败就返回 nil,由调用方按「拿不到 token」处理。
|
||||
if not save(seq, items) then return nil, "写入失败" end
|
||||
if not save(seq, items) then return nil, "Write failed" end
|
||||
return tok
|
||||
end
|
||||
return meta.token
|
||||
@@ -309,14 +310,14 @@ end
|
||||
function M.generate_link(token, target, opts)
|
||||
opts = opts or {}
|
||||
local id = id_by_token(token)
|
||||
if not id then return nil, nil, nil, "无效的订阅 token" end
|
||||
if not id then return nil, nil, nil, "Invalid subscription token" end
|
||||
local meta = M.get(id)
|
||||
local nodes = M.read_nodes(id)
|
||||
if #nodes == 0 then return nil, nil, nil, "暂无可用的节点(请先更新订阅)" end
|
||||
if #nodes == 0 then return nil, nil, nil, "No available nodes (update the subscription first)" end
|
||||
|
||||
local output = require("substore.output")
|
||||
local content, err = output.generate(nodes, target, opts)
|
||||
if not content then return nil, nil, nil, err or "无法生成目标格式" end
|
||||
if not content then return nil, nil, nil, err or "Failed to generate the target format" end
|
||||
|
||||
local ct = opts.content_type or output.content_type_for(target) or "text/plain; charset=utf-8"
|
||||
local ext = output.extension_for(target) or "txt"
|
||||
@@ -331,11 +332,11 @@ end
|
||||
M.CLEAR = setmetatable({}, { __tostring = function() return "substore.CLEAR" end })
|
||||
|
||||
function M.save_meta(id, patch)
|
||||
if not id_is_valid(id) then return false, "非法 ID" end
|
||||
if not id_is_valid(id) then return false, "Invalid ID" end
|
||||
local seq, items, lerr = load()
|
||||
if lerr then return false, lerr end
|
||||
local meta = items[id]
|
||||
if not meta then return false, "订阅不存在" end
|
||||
if not meta then return false, "Subscription not found" end
|
||||
for k, v in pairs(patch or {}) do
|
||||
if v == nil or v == M.CLEAR then meta[k] = nil else meta[k] = v end
|
||||
end
|
||||
@@ -377,7 +378,7 @@ function M.remove(id)
|
||||
-- 而调用方拿到 true,以为删成功了。
|
||||
-- 组合的 sources 剪除同理:没落盘的改动不重算(重算只会从磁盘读回旧状态)。
|
||||
local ok, serr = save(seq, items)
|
||||
if not ok then return false, serr or "写入失败" end
|
||||
if not ok then return false, serr or "Write failed" end
|
||||
os.remove(M.nodes_file(id))
|
||||
-- 组合的物化节点在 nodes/<combo>.json,只有 combo_refresh 会重写它。
|
||||
-- 不在这里重算的话,组合的下载链接会继续吐已删订阅的节点,一直等到别的源
|
||||
@@ -430,7 +431,7 @@ function M.sync(id)
|
||||
local log = function(msg) os.execute("logger -t luci-app-substore " .. util.shq(msg)) end
|
||||
log("Sync start id="..tostring(id))
|
||||
local meta = M.get(id)
|
||||
if not meta then log("Sync fail: subscription not found"); return nil, "订阅不存在" end
|
||||
if not meta then log("Sync fail: subscription not found"); return nil, "Subscription not found" end
|
||||
-- 组合订阅:无下载源,直接重算合并节点
|
||||
if M.is_combo(meta) then
|
||||
log("Sync combo refresh")
|
||||
@@ -444,29 +445,29 @@ function M.sync(id)
|
||||
local content = meta.raw_content or ""
|
||||
if content == "" then
|
||||
-- 失败路径一律不改 node_count:磁盘上的旧节点仍在,订阅链接仍在下发(§35)
|
||||
M.save_meta(id, { error = "本地订阅内容为空", last_update = os.time() })
|
||||
return nil, "本地订阅内容为空"
|
||||
M.save_meta(id, { error = "Local subscription content is empty", last_update = os.time() })
|
||||
return nil, "Local subscription content is empty"
|
||||
end
|
||||
local res, perr = parser.parse_local(content, meta.local_mode or "text")
|
||||
if not res or not res.nodes then
|
||||
log("Parse local fail: " .. tostring(perr))
|
||||
M.save_meta(id, { error = perr or "本地解析失败", last_update = os.time() })
|
||||
return nil, perr or "本地解析失败"
|
||||
M.save_meta(id, { error = perr or "Failed to parse local content", last_update = os.time() })
|
||||
return nil, perr or "Failed to parse local content"
|
||||
end
|
||||
log("Parse local ok nodes="..#res.nodes)
|
||||
local nodes = M.apply_rules(res.nodes, meta)
|
||||
if not M.write_nodes(id, nodes) then
|
||||
M.save_meta(id, { error = "写入节点数据失败", last_update = os.time() })
|
||||
return nil, "写入节点数据失败"
|
||||
M.save_meta(id, { error = "Failed to write node data", last_update = os.time() })
|
||||
return nil, "Failed to write node data"
|
||||
end
|
||||
local ok = M.save_meta(id, {
|
||||
node_count = #nodes, format = res.format or "local", error = "", last_update = os.time(),
|
||||
})
|
||||
if not ok then return nil, "更新状态失败" end
|
||||
if not ok then return nil, "Failed to update status" end
|
||||
M.refresh_combos(id)
|
||||
return #nodes
|
||||
end
|
||||
if not meta.url or meta.url == "" then log("Sync fail: no URL"); return nil, "无订阅 URL" end
|
||||
if not meta.url or meta.url == "" then log("Sync fail: no URL"); return nil, "No subscription URL" end
|
||||
|
||||
-- 订阅代理:开启时代理地址必须有效。无效就明确失败——
|
||||
-- 静默直连会让用户以为流量走了代理,属于必须避免的 silent fallback(§12)。
|
||||
@@ -474,10 +475,10 @@ function M.sync(id)
|
||||
if meta.proxy_enable == true or meta.proxy_enable == "1" then
|
||||
local p, perr = http.parse_proxy(meta.proxy or "")
|
||||
if not p or p == "" then
|
||||
local msg = perr or "代理地址为空"
|
||||
log("Proxy invalid: " .. tostring(msg))
|
||||
M.save_meta(id, { error = "代理配置无效: " .. tostring(msg), last_update = os.time() })
|
||||
return nil, "代理配置无效: " .. tostring(msg)
|
||||
local perr_text = perr or "Proxy address is empty"
|
||||
log("Proxy invalid: " .. tostring(perr_text))
|
||||
M.save_meta(id, { error = msg.join("Invalid proxy config: ", perr_text), last_update = os.time() })
|
||||
return nil, msg.join("Invalid proxy config: ", perr_text)
|
||||
end
|
||||
proxy = p
|
||||
end
|
||||
@@ -490,8 +491,8 @@ function M.sync(id)
|
||||
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)
|
||||
M.save_meta(id, { error = msg.join("Invalid User-Agent: ", uaerr), last_update = os.time() })
|
||||
return nil, msg.join("Invalid User-Agent: ", uaerr)
|
||||
end
|
||||
if ua ~= "" then log("Using User-Agent " .. ua) end
|
||||
|
||||
@@ -499,7 +500,7 @@ function M.sync(id)
|
||||
proxy = proxy, user_agent = ua })
|
||||
if not content then
|
||||
-- 下载工具的报错可能回显含凭据的 URL,写日志与入库前先抹掉(§39)
|
||||
local safe_err = http.scrub_credentials(err or "下载失败")
|
||||
local safe_err = http.scrub_credentials(err or "Download failed")
|
||||
log("Download fail: " .. safe_err)
|
||||
M.save_meta(id, { error = safe_err, last_update = os.time() })
|
||||
return nil, safe_err
|
||||
@@ -512,15 +513,15 @@ function M.sync(id)
|
||||
local res, perr = parser.parse(content)
|
||||
if not res or not res.nodes then
|
||||
log("Parse fail: " .. tostring(perr))
|
||||
M.save_meta(id, { error = perr or "解析失败", last_update = os.time() })
|
||||
return nil, perr or "解析失败"
|
||||
M.save_meta(id, { error = perr or "Parse failed", last_update = os.time() })
|
||||
return nil, perr or "Parse failed"
|
||||
end
|
||||
log("Parse ok nodes="..#res.nodes)
|
||||
|
||||
local nodes = M.apply_rules(res.nodes, meta)
|
||||
if not M.write_nodes(id, nodes) then
|
||||
M.save_meta(id, { error = "写入节点数据失败", last_update = os.time() })
|
||||
return nil, "写入节点数据失败"
|
||||
M.save_meta(id, { error = "Failed to write node data", last_update = os.time() })
|
||||
return nil, "Failed to write node data"
|
||||
end
|
||||
|
||||
local ok = M.save_meta(id, {
|
||||
@@ -532,7 +533,7 @@ function M.sync(id)
|
||||
total = (ui and ui.total) or M.CLEAR,
|
||||
expire = (ui and ui.expire) or M.CLEAR,
|
||||
})
|
||||
if not ok then return nil, "更新状态失败" end
|
||||
if not ok then return nil, "Failed to update status" end
|
||||
-- 源订阅更新后,刷新引用它的组合订阅
|
||||
M.refresh_combos(id)
|
||||
return #nodes
|
||||
@@ -668,18 +669,18 @@ end
|
||||
|
||||
-- 重新计算组合节点并落盘,更新状态。成功返回 node_count,失败返回 nil, err
|
||||
function M.combo_refresh(id)
|
||||
if not id_is_valid(id) then return nil, "非法 ID" end
|
||||
if not id_is_valid(id) then return nil, "Invalid ID" end
|
||||
local meta = M.get(id)
|
||||
if not meta then return nil, "订阅不存在" end
|
||||
if not meta then return nil, "Subscription not found" end
|
||||
local srcs = type(meta.sources) == "table" and meta.sources or {}
|
||||
if #srcs == 0 then
|
||||
M.save_meta(id, { error = "请选择至少一个订阅", node_count = 0, last_update = os.time() })
|
||||
return nil, "请选择至少一个订阅"
|
||||
M.save_meta(id, { error = "Select at least one subscription", node_count = 0, last_update = os.time() })
|
||||
return nil, "Select at least one subscription"
|
||||
end
|
||||
local nodes = M.combo_nodes(meta)
|
||||
if not M.write_nodes(id, nodes) then
|
||||
M.save_meta(id, { error = "写入节点数据失败", last_update = os.time() })
|
||||
return nil, "写入节点数据失败"
|
||||
M.save_meta(id, { error = "Failed to write node data", last_update = os.time() })
|
||||
return nil, "Failed to write node data"
|
||||
end
|
||||
M.save_meta(id, { node_count = #nodes, error = "", last_update = os.time() })
|
||||
return #nodes
|
||||
@@ -703,7 +704,7 @@ end
|
||||
function M.add_combo(name, sources, opts)
|
||||
name = util.trim(name or "")
|
||||
opts = opts or {}
|
||||
if name == "" then return nil, "名称不能为空" end
|
||||
if name == "" then return nil, "Name cannot be empty" end
|
||||
local srcs = {}
|
||||
if type(sources) == "table" then
|
||||
for _, s in ipairs(sources) do
|
||||
@@ -711,7 +712,7 @@ function M.add_combo(name, sources, opts)
|
||||
if s ~= "" and id_is_valid(s) then srcs[#srcs + 1] = s end
|
||||
end
|
||||
end
|
||||
if #srcs == 0 then return nil, "请选择至少一个订阅" end
|
||||
if #srcs == 0 then return nil, "Select at least one subscription" end
|
||||
local seq, items, lerr = load()
|
||||
if lerr then return nil, lerr end
|
||||
seq = seq + 1
|
||||
@@ -729,16 +730,16 @@ function M.add_combo(name, sources, opts)
|
||||
dedup = (opts.dedup == true or opts.dedup == "1") and "1" or "0",
|
||||
rename_map = opts.rename_map or "",
|
||||
}
|
||||
if not save(seq, items) then return nil, "写入失败" end
|
||||
if not save(seq, items) then return nil, "Write failed" end
|
||||
M.combo_refresh(id)
|
||||
return id
|
||||
end
|
||||
|
||||
-- 编辑组合订阅:更新名称/来源/规则后重算物化节点。成功返回 node_count,失败返回 nil, err
|
||||
function M.save_combo(id, name, sources, opts)
|
||||
if not id_is_valid(id) then return nil, "非法 ID" end
|
||||
if not id_is_valid(id) then return nil, "Invalid ID" end
|
||||
name = util.trim(name or "")
|
||||
if name == "" then return nil, "名称不能为空" end
|
||||
if name == "" then return nil, "Name cannot be empty" end
|
||||
local srcs = {}
|
||||
if type(sources) == "table" then
|
||||
for _, s in ipairs(sources) do
|
||||
@@ -746,7 +747,7 @@ function M.save_combo(id, name, sources, opts)
|
||||
if s ~= "" and id_is_valid(s) and s ~= id then srcs[#srcs + 1] = s end
|
||||
end
|
||||
end
|
||||
if #srcs == 0 then return nil, "请选择至少一个订阅" end
|
||||
if #srcs == 0 then return nil, "Select at least one subscription" end
|
||||
opts = opts or {}
|
||||
M.save_meta(id, {
|
||||
name = name, sources = srcs,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
-- luci-app-substore
|
||||
|
||||
local util = require("substore.util")
|
||||
local msg = require("substore.msg")
|
||||
|
||||
local M = {}
|
||||
|
||||
@@ -125,9 +126,9 @@ end
|
||||
function M.parse_url(url)
|
||||
url = util.trim(url or "")
|
||||
local scheme, rest = url:match("^([%w%+%-%.]+)://(.*)$")
|
||||
if not scheme then return nil, "无效 URL" end
|
||||
if not scheme then return nil, "Invalid URL" end
|
||||
scheme = scheme:lower()
|
||||
if scheme ~= "http" and scheme ~= "https" then return nil, "仅支持 http/https" end
|
||||
if scheme ~= "http" and scheme ~= "https" then return nil, "Only http/https is supported" end
|
||||
-- 主机部分到第一个 / ? # 为止(RFC 3986)。只剥 "/" 是不够的:
|
||||
-- "http://127.0.0.1?a=1" 会剩下 "127.0.0.1?a=1",它既不是合法主机名也不是
|
||||
-- 数值型 IPv4,DNS 必然解析不出来,能不能拦住就全看本机有没有解析能力
|
||||
@@ -147,7 +148,7 @@ function M.parse_url(url)
|
||||
local host, port
|
||||
if hostport:sub(1, 1) == "[" then
|
||||
local close = hostport:find("]", 1, true)
|
||||
if not close then return nil, "无效主机名" end
|
||||
if not close then return nil, "Invalid hostname" end
|
||||
host = hostport:sub(2, close - 1)
|
||||
port = hostport:sub(close + 1):match("^:(%d+)$") or (scheme == "https" and "443" or "80")
|
||||
else
|
||||
@@ -158,13 +159,13 @@ function M.parse_url(url)
|
||||
host, port = hostport, (scheme == "https" and "443" or "80")
|
||||
end
|
||||
end
|
||||
if not host or host == "" then return nil, "无效主机名" end
|
||||
if not host or host == "" then return nil, "Invalid hostname" end
|
||||
-- 端口范围校验:`(%d+)` 只保证是数字,`http://host:99999/` 与 `:0` 都能通过,
|
||||
-- 而这两个端口连不出去 —— 下载必然失败,却会先经过一轮 DNS/SSRF 检查,
|
||||
-- 报出来的是「连接失败」这种指错方向的原因。parse_proxy 早已做了同样的校验,
|
||||
-- 这里对齐。
|
||||
local pn = tonumber(port)
|
||||
if not pn or pn < 1 or pn > 65535 then return nil, "端口无效" end
|
||||
if not pn or pn < 1 or pn > 65535 then return nil, "Invalid port" end
|
||||
return { scheme = scheme, host = host, port = tostring(pn) }
|
||||
end
|
||||
|
||||
@@ -182,8 +183,8 @@ 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
|
||||
if #s > 256 then return nil, "User-Agent is too long (at most 256 characters)" end
|
||||
if s:find("%c") then return nil, "User-Agent must not contain control characters" end
|
||||
return s
|
||||
end
|
||||
|
||||
@@ -191,11 +192,11 @@ function M.parse_proxy(p)
|
||||
local s = util.trim(p or "")
|
||||
if s == "" then return "" end
|
||||
local scheme, rest = s:match("^(%a[%w]*)://(.*)$")
|
||||
if not scheme then return nil, "代理格式无效" end
|
||||
if not scheme then return nil, "Invalid proxy format" end
|
||||
scheme = scheme:lower()
|
||||
if scheme ~= "http" and scheme ~= "https" and scheme ~= "socks4"
|
||||
and scheme ~= "socks5" and scheme ~= "socks5h" then
|
||||
return nil, "不支持的代理协议: " .. scheme
|
||||
return nil, msg.join("Unsupported proxy protocol: ", scheme)
|
||||
end
|
||||
local userinfo, hostport = "", rest:gsub("/.*$", "")
|
||||
local at = hostport:find("@", 1, true)
|
||||
@@ -204,7 +205,7 @@ function M.parse_proxy(p)
|
||||
hostport = hostport:sub(at + 1)
|
||||
end
|
||||
if userinfo ~= "" and not userinfo:match("^[%w%.%-_:]+$") then
|
||||
return nil, "代理用户名/密码含非法字符"
|
||||
return nil, "Proxy username/password contains invalid characters"
|
||||
end
|
||||
local host, port
|
||||
local bracketed = hostport:sub(1, 1) == "["
|
||||
@@ -216,18 +217,18 @@ function M.parse_proxy(p)
|
||||
if h then host, port = h, prt else host = hostport end
|
||||
end
|
||||
if not host or host == "" or not host:match("^[%w%.%-%:]+$") then
|
||||
return nil, "代理主机无效"
|
||||
return nil, "Invalid proxy host"
|
||||
end
|
||||
-- 未加方括号的 IPv6 字面量:RFC 3986 要求 IPv6 主机必须写成 [addr]。
|
||||
-- 没有方括号时 `::1:1080` 既可能是「地址 ::1 + 端口 1080」,也可能就是
|
||||
-- 地址 `::1:1080` —— 无法判定。与其猜一个再拼出一条 curl -x 解析不了的
|
||||
-- 代理串(静默失效),不如明确报错让用户补上方括号。
|
||||
if not bracketed and select(2, host:gsub(":", "")) > 1 then
|
||||
return nil, "IPv6 代理地址必须加方括号,例如 http://[::1]:1080"
|
||||
return nil, "An IPv6 proxy address must be bracketed, e.g. http://[::1]:1080"
|
||||
end
|
||||
if port then
|
||||
port = tonumber(port)
|
||||
if not port or port < 1 or port > 65535 then return nil, "代理端口无效" end
|
||||
if not port or port < 1 or port > 65535 then return nil, "Invalid proxy port" end
|
||||
end
|
||||
-- IPv6 字面量必须把方括号拼回去:上面为做主机校验把 [::1] 拆成了 ::1,
|
||||
-- 直接拼会得到 `http://::1:1080` —— 冒号歧义,curl -x 与 http_proxy= 都
|
||||
@@ -324,33 +325,33 @@ local LOOPBACK_NAMES = {
|
||||
-- 调用方(下载层)据此在连接建立后强制复核对端地址,见 verify_peer_ip。
|
||||
function M.check_public(host)
|
||||
host = util.trim(host or ""):lower()
|
||||
if host == "" then return false, "空主机名" end
|
||||
if host == "" then return false, "Empty hostname" end
|
||||
-- userinfo 应已被 parse_url 剥掉;这里再挡一次。
|
||||
-- "evil@127.0.0.1" 不是合法主机名,DNS 解析必然失败,而解析失败是放行的
|
||||
-- (fail-open),于是会绕过检查 —— 实测该写法确实能连到 127.0.0.1。
|
||||
if host:find("@", 1, true) then return false, "主机名含非法字符 @" end
|
||||
if host:find("@", 1, true) then return false, "Hostname contains the invalid character @" end
|
||||
-- 空白 / 百分号编码:主机名里都不合法("127.0.0.1%00.example.com" 这类
|
||||
-- 截断写法当前版本的 curl 会直接判 URL 非法,但不同版本行为不一,直接拒绝更稳)
|
||||
if host:find("[%s%%]") then return false, "主机名含非法字符" end
|
||||
if host:find("[%s%%]") then return false, "Hostname contains invalid characters" end
|
||||
-- 去掉尾部点:"localhost." 与 "localhost" 指向同一台机器,
|
||||
-- 不归一会被当成普通域名交给 DNS,解析失败时即放行。
|
||||
host = host:gsub("%.$", "")
|
||||
if host == "" then return false, "空主机名" end
|
||||
if host == "" then return false, "Empty hostname" end
|
||||
|
||||
if host:find(":", 1, true) then
|
||||
if not looks_like_ipv6(host) then return false, "无效主机名" end
|
||||
if is_private_ipv6(host) then return false, "目标为内网/保留地址" end
|
||||
if not looks_like_ipv6(host) then return false, "Invalid hostname" end
|
||||
if is_private_ipv6(host) then return false, "Target is a private/reserved address" end
|
||||
return true
|
||||
end
|
||||
|
||||
-- 数值型 IPv4(十进制 / 八进制 / 十六进制 / 短写)按 inet_aton 语义归一后再判私网
|
||||
local norm = normalize_ipv4(host)
|
||||
if norm then
|
||||
if is_private_ipv4(norm) then return false, "目标为内网/保留地址" end
|
||||
if is_private_ipv4(norm) then return false, "Target is a private/reserved address" end
|
||||
return true
|
||||
end
|
||||
|
||||
if LOOPBACK_NAMES[host] then return false, "目标为 localhost" end
|
||||
if LOOPBACK_NAMES[host] then return false, "Target is localhost" end
|
||||
|
||||
local ips, have_resolver = resolve(host)
|
||||
if not ips then
|
||||
@@ -362,7 +363,7 @@ function M.check_public(host)
|
||||
-- 只要本机这一刻解析不出来(解析器临时故障、超时),就绕过了上面
|
||||
-- 全部检查被放行,而 curl 自己仍会把它解析到 127.0.0.1 并连上去。
|
||||
-- 检查的强度不能低于被检查者。
|
||||
return false, "无法解析目标主机名"
|
||||
return false, "Cannot resolve the target hostname"
|
||||
end
|
||||
-- 本机连一个解析手段都没有(nixio 不可用,且没有 nslookup)。
|
||||
--
|
||||
@@ -376,12 +377,12 @@ function M.check_public(host)
|
||||
-- 实际对端地址(见 verify_peer_ip)。那才是真正连上去的那个 IP,
|
||||
-- 比预检更贴近事实,顺带免疫「预检时解析到公网、连接时解析到内网」的
|
||||
-- DNS rebinding。
|
||||
return true, "本机无 DNS 解析能力,改由连接时校验对端地址", true
|
||||
return true, "No local DNS resolution; the peer address will be verified at connect time", true
|
||||
end
|
||||
for _, ip in ipairs(ips) do
|
||||
if not is_private(ip) then return true end
|
||||
end
|
||||
return false, "目标仅解析到内网/保留地址"
|
||||
return false, "Target resolves only to private/reserved addresses"
|
||||
end
|
||||
|
||||
-- ---------- 下载 ----------
|
||||
@@ -444,9 +445,9 @@ function M.validate_redirect_chain(base_url, log)
|
||||
for _, loc in ipairs(locations_from_log(log)) do
|
||||
local next_url = resolve_url(base_url, loc)
|
||||
local np = M.parse_url(next_url)
|
||||
if not np then return false, "重定向目标无效: " .. tostring(loc) end
|
||||
if not np then return false, msg.join("Invalid redirect target: ", loc) end
|
||||
local ok, re = M.check_public(np.host)
|
||||
if not ok then return false, "重定向目标不安全: " .. (re or "") end
|
||||
if not ok then return false, msg.join("Unsafe redirect target: ", re or "") end
|
||||
end
|
||||
return true
|
||||
end
|
||||
@@ -480,13 +481,13 @@ local function verify_peer_ip(ip, proxy, unverified)
|
||||
-- 预检时本机就没有解析能力(check_public 放行了),现在连对端地址
|
||||
-- 也拿不到 —— 这个请求从头到尾没有任何一处校验过目标,
|
||||
-- 必须拒绝,否则「无法校验」就等于「放行」。
|
||||
return false, "无法校验目标地址(本机无 DNS 解析能力,且未取得对端 IP)"
|
||||
return false, "Cannot verify the target address (no local DNS resolution and no peer IP obtained)"
|
||||
end
|
||||
-- 有解析能力时预检已经查过一轮,这里无从复核不额外拒绝。
|
||||
return true
|
||||
end
|
||||
if is_private(ip) then
|
||||
return false, "目标实际连接到内网/保留地址 (" .. ip .. ")"
|
||||
return false, msg.compose("Target actually connects to a private/reserved address (", ip, ")")
|
||||
end
|
||||
return true
|
||||
end
|
||||
@@ -533,9 +534,9 @@ local function fetch_curl(url, parsed, opts)
|
||||
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 "下载失败")
|
||||
local errtext = util.trim(util.read_file(errf) or "Download failed")
|
||||
cleanup()
|
||||
return nil, msg
|
||||
return nil, errtext
|
||||
end
|
||||
-- 只把 2xx 当成功。写成 [23] 会把 3xx 也当成功,于是重定向响应体(通常是空的
|
||||
-- 或一段 HTML)被当成订阅内容存下去:已存的节点被清空、node_count 归 0,
|
||||
@@ -549,12 +550,12 @@ local function fetch_curl(url, parsed, opts)
|
||||
local size = util.file_size(tmp)
|
||||
if size > max then
|
||||
cleanup()
|
||||
return nil, "响应超过大小限制 (" .. max .. " 字节)"
|
||||
return nil, msg.compose("Response exceeds the size limit (", max, " bytes)")
|
||||
end
|
||||
local content = util.read_file(tmp)
|
||||
local headers = read_headers(hdr)
|
||||
cleanup()
|
||||
if not content then return nil, "读取响应失败" end
|
||||
if not content then return nil, "Failed to read the response" end
|
||||
return content, headers
|
||||
end
|
||||
if code:match("^3%d%d$") then
|
||||
@@ -569,27 +570,27 @@ local function fetch_curl(url, parsed, opts)
|
||||
local loc = location_from_headers(hdr)
|
||||
if not loc then
|
||||
cleanup()
|
||||
return nil, "重定向无 Location"
|
||||
return nil, "Redirect without a Location header"
|
||||
end
|
||||
local next_url = resolve_url(cur, loc)
|
||||
local np = M.parse_url(next_url)
|
||||
if not np then
|
||||
cleanup()
|
||||
return nil, "重定向目标无效"
|
||||
return nil, "Invalid redirect target"
|
||||
end
|
||||
local ok, re = M.check_public(np.host)
|
||||
if not ok then
|
||||
cleanup()
|
||||
return nil, "重定向目标不安全: " .. (re or "")
|
||||
return nil, msg.join("Unsafe redirect target: ", re or "")
|
||||
end
|
||||
cleanup()
|
||||
cur = next_url
|
||||
else
|
||||
cleanup()
|
||||
return nil, "HTTP 错误 " .. code
|
||||
return nil, msg.join("HTTP error ", code)
|
||||
end
|
||||
end
|
||||
return nil, "重定向次数过多"
|
||||
return nil, "Too many redirects"
|
||||
end
|
||||
|
||||
-- wget 后端的代理环境变量。busybox wget 只能通过 http_proxy/https_proxy 环境变量
|
||||
@@ -600,8 +601,8 @@ function M.wget_proxy_env(proxy)
|
||||
local scheme = tostring(proxy):match("^(%a[%w]*)://")
|
||||
scheme = scheme and scheme:lower() or ""
|
||||
if scheme ~= "http" and scheme ~= "https" then
|
||||
return nil, "当前下载后端 (wget) 不支持 " .. string.upper(scheme) ..
|
||||
" 代理,请安装 curl 或改用 http 代理"
|
||||
return nil, msg.compose("The current download backend (wget) does not support ", string.upper(scheme),
|
||||
" proxies; install curl or use an http proxy")
|
||||
end
|
||||
return "http_proxy=" .. util.shq(proxy) .. " https_proxy=" .. util.shq(proxy) .. " "
|
||||
end
|
||||
@@ -614,7 +615,7 @@ local function fetch_wget(url, parsed, opts)
|
||||
-- 于是在这条路径上,「预检放行」之后不存在任何一处校验 —— 等于没有 SSRF 防护。
|
||||
-- 与 verify_peer_ip 的处置保持一致:校验不了就拒绝,而不是放行。
|
||||
if opts.unverified then
|
||||
return nil, "本机无 DNS 解析能力,wget 后端无法校验目标地址,已拒绝下载(安装 curl 后重试)"
|
||||
return nil, "No local DNS resolution: the wget backend cannot verify the target address and refused the download (install curl and retry)"
|
||||
end
|
||||
local max, t = opts.max_size, opts.timeout
|
||||
local proxy_env, perr = M.wget_proxy_env(opts.proxy)
|
||||
@@ -645,15 +646,15 @@ local function fetch_wget(url, parsed, opts)
|
||||
return nil, reason
|
||||
end
|
||||
local size = util.file_size(tmp)
|
||||
if size > max then os.remove(tmp); return nil, "响应超过大小限制 (" .. max .. " 字节)" end
|
||||
if size > max then os.remove(tmp); return nil, msg.compose("Response exceeds the size limit (", max, " bytes)") end
|
||||
if not exit_ok then
|
||||
os.remove(tmp)
|
||||
return nil, "下载失败(wget 退出码非 0)"
|
||||
return nil, "Download failed (wget exit code is non-zero)"
|
||||
end
|
||||
if size == 0 then os.remove(tmp); return nil, "下载失败或内容为空" end
|
||||
if size == 0 then os.remove(tmp); return nil, "Download failed or the content is empty" end
|
||||
local content = util.read_file(tmp)
|
||||
os.remove(tmp)
|
||||
if not content then return nil, "读取响应失败" end
|
||||
if not content then return nil, "Failed to read the response" end
|
||||
return content, {} -- wget 不捕获响应头(无 subscription-userinfo)
|
||||
end
|
||||
|
||||
@@ -670,13 +671,13 @@ function M.download(url, opts)
|
||||
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
|
||||
if not parsed then return nil, nil, "Invalid URL" end
|
||||
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
|
||||
if not tool then return nil, nil, "No download tool available (curl/wget)" end
|
||||
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
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
-- msg.lua — 组合型用户可见消息的拼接与还原(纯 Lua,不 require luci.i18n)
|
||||
--
|
||||
-- 为什么需要它:
|
||||
-- 后端模块**不能** require("luci.i18n") —— substore-cron.sh 在独立的 lua 进程里
|
||||
-- 跑 core.sync,那里没有 LuCI 环境。所以后端一律返回语言中立的英文 msgid,
|
||||
-- 翻译只发生在显示边界(控制器 / 视图)。
|
||||
--
|
||||
-- 但有一类消息是「msgid 前缀 + 动态值」拼出来的,例如:
|
||||
-- "Invalid proxy config: " .. reason
|
||||
-- 整体查翻译表必然落空 —— 值每次都不同 —— 前缀那半句就永远翻译不了。
|
||||
-- 这里用分隔符把各段拼起来,显示边界按分隔符拆开、**逐段**查表,
|
||||
-- 于是前缀也能翻译,动态值原样输出。
|
||||
--
|
||||
-- 分隔符取 "\1"(SOH):它不会出现在任何合法取值里(URL / 主机名 / User-Agent /
|
||||
-- 端口 / 协议名在 http.lua 里都校验过),而且 util.json_encode 会把控制字符
|
||||
-- 转义成 \u0001,所以组合消息存进 meta.error 再读回来仍然完好。
|
||||
--
|
||||
-- 拆分只按**第一个**分隔符切,剩下的递归处理,所以嵌套(值本身又是组合消息)
|
||||
-- 天然成立。全程精确,没有任何启发式匹配。
|
||||
local M = {}
|
||||
|
||||
M.SEP = "\1"
|
||||
|
||||
-- 把若干段依次拼成一条组合消息。段可以是 msgid(会被翻译),
|
||||
-- 也可以是要原样显示的动态值(查表落空即原样输出)。
|
||||
function M.compose(...)
|
||||
local out = {}
|
||||
for i = 1, select("#", ...) do
|
||||
if i > 1 then out[#out + 1] = M.SEP end
|
||||
out[#out + 1] = tostring((select(i, ...)))
|
||||
end
|
||||
return table.concat(out)
|
||||
end
|
||||
|
||||
-- compose 的两段简写,读起来更贴近原来的 `前缀 .. 值`。
|
||||
function M.join(prefix, value)
|
||||
return M.compose(prefix, value)
|
||||
end
|
||||
|
||||
-- 把一串 msgid 用 sep 连成组合消息。sep 自己也是一段(也参与查表),
|
||||
-- 因为中文里的连接符未必与英文相同。
|
||||
function M.compose_list(parts, sep)
|
||||
local out = {}
|
||||
for i, p in ipairs(parts) do
|
||||
if i > 1 then out[#out + 1] = M.SEP .. tostring(sep) .. M.SEP end
|
||||
out[#out + 1] = tostring(p)
|
||||
end
|
||||
return table.concat(out)
|
||||
end
|
||||
|
||||
-- 翻译一条(可能是组合消息的)用户可见文案。translate 由调用方传入 ——
|
||||
-- 本模块不 require luci.i18n(见文件头)。
|
||||
-- 对**已经翻译过**的串是幂等的:它不含分隔符,整串查表落空即原样返回。
|
||||
function M.translate(s, translate)
|
||||
if s == nil then return nil end
|
||||
s = tostring(s)
|
||||
local i = s:find(M.SEP, 1, true)
|
||||
if not i then return translate(s) end
|
||||
return translate(s:sub(1, i - 1)) .. M.translate(s:sub(i + 1), translate)
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
-- util 是叶子模块(自身不 require 任何 substore.*),所以这里不会形成循环依赖。
|
||||
local util = require("substore.util")
|
||||
local msg = require("substore.msg")
|
||||
|
||||
local M = {}
|
||||
|
||||
@@ -766,8 +767,8 @@ function M.validate_rename_map(rule_str)
|
||||
end
|
||||
end)
|
||||
if not ok then
|
||||
return false, string.format("重命名规则第 %d 行:正则表达式无效(%s)",
|
||||
r.line_no or 0, r.pattern or "")
|
||||
return false, msg.compose("Rename rule line ", r.line_no or 0,
|
||||
": invalid regular expression (", r.pattern or "", ")")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,7 +6,9 @@ local output_uri = require("substore.output_uri")
|
||||
local output_singbox = require("substore.output_singbox")
|
||||
local output_v2ray = require("substore.output_v2ray")
|
||||
local output_formats = require("substore.output_formats")
|
||||
local output_egern = require("substore.output_egern")
|
||||
local output_wgconf = require("substore.output_wireguard_conf")
|
||||
local msg = require("substore.msg")
|
||||
|
||||
local M = {}
|
||||
|
||||
@@ -60,6 +62,10 @@ local CONTENT_TYPES = {
|
||||
surfboard = "text/plain; charset=utf-8",
|
||||
surgemac = "text/plain; charset=utf-8",
|
||||
loon = "text/plain; charset=utf-8",
|
||||
-- Egern 的配置正文是 YAML,但 Content-Type 与其它 YAML 格式(clash /
|
||||
-- clashmeta / stash)保持 text/plain 一致:这三个是本仓库既有的约定,
|
||||
-- 单独把 egern 改成 application/yaml 只会让同一类内容出现两种类型。
|
||||
-- (后缀已按内容改成 .yaml,见 FILENAME_EXT。)
|
||||
egern = "text/plain; charset=utf-8",
|
||||
qx = "text/plain; charset=utf-8",
|
||||
shadowrocket = "text/plain; charset=utf-8",
|
||||
@@ -102,7 +108,9 @@ local FILENAME_EXT = {
|
||||
surfboard = "conf",
|
||||
surgemac = "conf",
|
||||
loon = "conf",
|
||||
egern = "conf",
|
||||
-- Egern 的配置是 YAML(output_egern.lua),后缀必须与内容一致:
|
||||
-- 下游按后缀判断格式的客户端会把 .conf 当成 Surge 的逗号行去解析。
|
||||
egern = "yaml",
|
||||
qx = "conf",
|
||||
shadowrocket = "txt",
|
||||
singbox = "json",
|
||||
@@ -142,12 +150,12 @@ M.FORMAT_OPTIONS = {
|
||||
function M.generate(nodes, format, options)
|
||||
-- 空串 / 纯空白等同于「未指定」。
|
||||
-- `?target=` 会传进来 "",而 "" 在 Lua 里是**真值**,所以 `format or DEFAULT_FORMAT`
|
||||
-- 兜不住它,会一路落到 "unsupported format: "(冒号后面什么都没有)——
|
||||
-- 兜不住它,会一路落到 "Unsupported output format: "(冒号后面什么都没有)——
|
||||
-- 用户拿到的是一个说不出原因的错误页。控制器只在 nil 时兜底,覆盖不到空串。
|
||||
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
|
||||
if not norm then return nil, msg.join("Unsupported output format: ", tostring(format)) end
|
||||
|
||||
if norm == "clashmeta" then return clash_meta.generate(nodes, options) end
|
||||
if norm == "clash" then return output_formats.to_clash(nodes, options) end
|
||||
@@ -156,7 +164,7 @@ function M.generate(nodes, format, options)
|
||||
if norm == "surfboard" then return output_formats.to_surfboard(nodes, options) end
|
||||
if norm == "surgemac" then return output_formats.to_surgemac(nodes, options) end
|
||||
if norm == "loon" then return output_formats.to_loon(nodes, options) end
|
||||
if norm == "egern" then return output_formats.to_egern(nodes, options) end
|
||||
if norm == "egern" then return output_egern.generate(nodes, options) end
|
||||
if norm == "shadowrocket" then return output_uri.to_shadowrocket(nodes) end
|
||||
if norm == "qx" then return output_formats.to_qx(nodes, options) end
|
||||
if norm == "singbox" then return output_singbox.generate(nodes) end
|
||||
@@ -165,7 +173,7 @@ function M.generate(nodes, format, options)
|
||||
if norm == "wgconf" then return output_wgconf.generate(nodes, options) end
|
||||
if norm == "plain" then return output_formats.to_plain(nodes) end
|
||||
|
||||
return nil, "unsupported format: " .. tostring(format)
|
||||
return nil, msg.join("Unsupported output format: ", tostring(format))
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -502,6 +502,11 @@ local function generate_groups(nodes, tags, options)
|
||||
return table.concat(out, "\n")
|
||||
end
|
||||
|
||||
-- YAML 标量转义。导出给 output_egern.lua 复用:Egern 的配置同样是给客户端解析的
|
||||
-- YAML,引号规则、控制字符、c-indicator 的处理必须与这里完全一致。另抄一份必然
|
||||
-- 漂移 —— 同一个「未加引号的 `password: %foo` 会让客户端拒绝整份配置」的坑。
|
||||
M.esc_yaml = esc_yaml
|
||||
|
||||
function M.generate(nodes, options)
|
||||
options = options or {}
|
||||
nodes = nodes or {}
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
-- output_egern.lua — Egern 配置输出(YAML,纯 Lua)
|
||||
-- luci-app-substore
|
||||
--
|
||||
-- Egern 的配置是 YAML,不是 Surge 的逗号行(LEGACY_ISSUES 的 7.2)。结构:
|
||||
--
|
||||
-- proxies: 节点列表;每一项是**单键映射**,键名即协议名(小写)
|
||||
-- policy_groups: 策略组列表;同样是单键映射(select / auto_test / fallback …)
|
||||
--
|
||||
-- 字段名一律 snake_case(user_id / peer_public_key / skip_tls_verify / udp_relay),
|
||||
-- 而统一模型混用 kebab-case 与 camelCase(public-key / skip-cert-verify / udp),
|
||||
-- 所以每个字段都要逐项翻译 —— 既不能照抄 Clash 的 network: + ws-opts,
|
||||
-- 也不能照抄 Surge 家族的逗号行。
|
||||
--
|
||||
-- 依据(逐字段核对官方文档,无推测项):
|
||||
-- * egernapp.com/docs/configuration/example/ —— 完整的官方配置示例,
|
||||
-- proxies / policy_groups 的真实结构与字段名;
|
||||
-- * egernapp.com/docs/configuration/proxies/ —— 每个协议的字段表,以及
|
||||
-- vmess / vless 的 transport 子对象(tls / ws / wss / http1 / http2 / grpc)
|
||||
-- 与 Reality 的嵌套位置(transport.<类型>.reality)。
|
||||
--
|
||||
-- 与 Surge 家族的分工:Egern **不再**是 surge_config 的一个 flavor。它的协议清单
|
||||
-- 与 Surge 不同(有 VLESS 与 WireGuard,没有 SSR 与 Hysteria v1),可表达的参数
|
||||
-- 也不同(WireGuard 在 Surge 的单行 [Proxy] 里表达不了,在 YAML 里可以)。
|
||||
|
||||
local util = require("substore.util")
|
||||
local clash_meta = require("substore.output_clash_meta")
|
||||
|
||||
local M = {}
|
||||
|
||||
-- YAML 标量转义与 Clash.Meta 输出共用一份实现(引号触发集、控制字符、
|
||||
-- c-indicator 的处理完全相同)。
|
||||
local esc_yaml = clash_meta.esc_yaml
|
||||
|
||||
-- 节点字段的缩进。YAML 层级:
|
||||
-- proxies: 0
|
||||
-- - shadowsocks: 2
|
||||
-- name: … 6
|
||||
local P = " "
|
||||
|
||||
-- 统一模型的协议名 → Egern 的协议键。
|
||||
--
|
||||
-- 官方协议清单(egernapp.com/docs/configuration/proxies/):
|
||||
-- Shadowsocks / Snell / Trojan / AnyTLS / Hysteria2 / TUIC / SOCKS5 /
|
||||
-- SOCKS5 over TLS / SSH / HTTP / HTTPS / Vmess / Vless / WireGuard
|
||||
--
|
||||
-- 清单里**没有**的两个协议必须整条丢弃(返回 nil),否则写出去的就是客户端
|
||||
-- 读不懂的节点 —— 与 surge_line 对未知协议的处置同一约定:
|
||||
-- * ssr(ShadowsocksR):清单里没有 SSR;
|
||||
-- * hysteria(v1):清单里只有 Hysteria2。两者的字段结构不同
|
||||
-- (v1 的 obfs 是普通字符串、没有 obfs_password),拿 Hysteria2 的键去顶
|
||||
-- 只会让客户端按错误的协议去连。
|
||||
local EGERN_KEY = {
|
||||
shadowsocks = "shadowsocks",
|
||||
vmess = "vmess",
|
||||
vless = "vless",
|
||||
trojan = "trojan",
|
||||
anytls = "anytls",
|
||||
hysteria2 = "hysteria2",
|
||||
tuic = "tuic",
|
||||
socks = "socks5",
|
||||
socks5 = "socks5",
|
||||
http = "http",
|
||||
wireguard = "wireguard",
|
||||
}
|
||||
|
||||
-- 有 udp_relay 字段的协议。取自官方字段表:Hysteria2(本身就是 UDP)与 TUIC
|
||||
-- (用 udp_relay_mode)没有这个键,HTTP 也没有;写了就是客户端读不懂的键。
|
||||
local UDP_RELAY = {
|
||||
shadowsocks = true, vmess = true, vless = true,
|
||||
trojan = true, anytls = true, socks5 = true,
|
||||
}
|
||||
|
||||
-- `key: value`。值为 nil / 空串时不输出(Egern 的必填字段由协议本身保证,
|
||||
-- 可选字段缺失时省略键即取默认值)。
|
||||
local function kv(lines, ind, k, v)
|
||||
if v == nil then return end
|
||||
v = tostring(v)
|
||||
if v == "" then return end
|
||||
lines[#lines + 1] = ind .. k .. ": " .. esc_yaml(v)
|
||||
end
|
||||
|
||||
-- 数组或逗号分隔字符串 → 数组。与 output_clash_meta 的 as_list 同源:
|
||||
-- alpn / dns / reserved 在统一模型里的形态取决于来源(Clash YAML / sing-box JSON
|
||||
-- 解析后是 table,表单导入后是 "h3,h2" 这样的字符串)。
|
||||
local function as_list(v)
|
||||
local out = {}
|
||||
if type(v) == "table" then
|
||||
for _, x in ipairs(v) do
|
||||
if x ~= nil and tostring(x) ~= "" then out[#out + 1] = x end
|
||||
end
|
||||
elseif type(v) == "string" then
|
||||
for x in v:gmatch("[^,]+") do
|
||||
x = x:match("^%s*(.-)%s*$")
|
||||
if x ~= "" then out[#out + 1] = x end
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- `key:` 后跟块序列。空列表直接不输出该键(Egern 的可选数组字段省略即默认)。
|
||||
local function kv_list(lines, ind, k, v)
|
||||
local items = as_list(v)
|
||||
if #items == 0 then return end
|
||||
lines[#lines + 1] = ind .. k .. ":"
|
||||
for _, x in ipairs(items) do
|
||||
lines[#lines + 1] = ind .. " - " .. esc_yaml(x)
|
||||
end
|
||||
end
|
||||
|
||||
-- 布尔值按 YAML 的裸 true / false 输出(加引号会变成字符串,语义不同)。
|
||||
local function yaml_bool(v)
|
||||
if v == nil then return nil end
|
||||
if v == false or v == "false" or v == "0" or v == 0 then return "false" end
|
||||
return "true"
|
||||
end
|
||||
|
||||
-- Reality 子对象:`reality: { public_key, short_id }`。
|
||||
--
|
||||
-- 键名是 snake_case 的 public_key / short_id —— 既不是统一模型的
|
||||
-- public-key / short-id,也不是 Clash 的 reality-opts。写错键名客户端不报错,
|
||||
-- 只是 Reality 静默失效(退回普通 TLS),所以这里逐字对照官方示例。
|
||||
--
|
||||
-- 判据取「有没有 public-key」而不是 security == "reality":公钥就是 Reality 的
|
||||
-- 凭据,缺了它客户端连不上;而 security 在两种来源里含义不同(分享链接写
|
||||
-- reality,Loon 的行只写 over-tls + public-key)。
|
||||
local function reality_block(lines, ind, n)
|
||||
if not n["public-key"] then return end
|
||||
lines[#lines + 1] = ind .. "reality:"
|
||||
lines[#lines + 1] = ind .. " public_key: " .. esc_yaml(n["public-key"])
|
||||
if n["short-id"] then
|
||||
lines[#lines + 1] = ind .. " short_id: " .. esc_yaml(n["short-id"])
|
||||
end
|
||||
end
|
||||
|
||||
-- vmess / vless 的传输层。
|
||||
--
|
||||
-- Egern 用 transport 子映射,键名是传输类型本身:tls / ws / wss / http1 /
|
||||
-- http2 / grpc。TLS 也是其中一种(transport.tls),**没有**顶层 tls 开关 ——
|
||||
-- 所以「明文 tcp」就是完全不写 transport。
|
||||
-- 这与 Clash 的 `network: ws` + `ws-opts` 是两套完全不同的写法。
|
||||
local function transport_block(lines, n)
|
||||
local net = (n.net or n.network or ""):lower()
|
||||
local tls = (n.security and n.security ~= "none")
|
||||
or (n.tls and n.tls ~= false and n.tls ~= "none")
|
||||
local sni = n.sni or n.servername
|
||||
local verify = yaml_bool(n["skip-cert-verify"])
|
||||
|
||||
local sub
|
||||
if net == "ws" then
|
||||
-- wss = ws 之上再套 TLS;官方文档把两者列为两个传输类型
|
||||
sub = tls and "wss" or "ws"
|
||||
elseif net == "grpc" then
|
||||
-- gRPC 恒定走 TLS(官方原文:"The TLS layer is always applied")
|
||||
sub = "grpc"
|
||||
elseif net == "h2" or net == "http" then
|
||||
sub = "http2"
|
||||
elseif tls or n["public-key"] then
|
||||
sub = "tls"
|
||||
else
|
||||
-- 明文 tcp:不写 transport
|
||||
return
|
||||
end
|
||||
|
||||
lines[#lines + 1] = P .. "transport:"
|
||||
local S = P .. " " -- transport 的子键(传输类型)
|
||||
local F = P .. " " -- 传输类型的字段
|
||||
|
||||
lines[#lines + 1] = S .. sub .. ":"
|
||||
if sub == "ws" or sub == "wss" then
|
||||
kv(lines, F, "path", n.path)
|
||||
if n.host then
|
||||
lines[#lines + 1] = F .. "headers:"
|
||||
lines[#lines + 1] = F .. " Host: " .. esc_yaml(n.host)
|
||||
end
|
||||
if sub == "wss" then
|
||||
kv(lines, F, "sni", sni)
|
||||
kv(lines, F, "skip_tls_verify", verify)
|
||||
reality_block(lines, F, n)
|
||||
end
|
||||
elseif sub == "grpc" then
|
||||
-- 服务名在 Egern 里叫 service_name(Clash 叫 grpc-service-name,
|
||||
-- sing-box 叫 service_name)。丢了这个参数客户端用默认服务名去连,
|
||||
-- 握手必然失败且不报错。
|
||||
kv(lines, F, "service_name", n.path)
|
||||
kv(lines, F, "sni", sni)
|
||||
kv(lines, F, "skip_tls_verify", verify)
|
||||
reality_block(lines, F, n)
|
||||
elseif sub == "http2" then
|
||||
kv(lines, F, "path", n.path)
|
||||
if n.host then
|
||||
lines[#lines + 1] = F .. "headers:"
|
||||
lines[#lines + 1] = F .. " Host: " .. esc_yaml(n.host)
|
||||
end
|
||||
kv(lines, F, "sni", sni)
|
||||
kv(lines, F, "skip_tls_verify", verify)
|
||||
else -- tls
|
||||
kv(lines, F, "sni", sni)
|
||||
kv(lines, F, "skip_tls_verify", verify)
|
||||
reality_block(lines, F, n)
|
||||
end
|
||||
end
|
||||
|
||||
-- SIP003 插件 → Egern 的 Shadowsocks 混淆三件套 obfs / obfs_host / obfs_uri。
|
||||
--
|
||||
-- Egern 的 Shadowsocks 只有这三个混淆字段(官方字段表),**没有**任何插件字段:
|
||||
-- v2ray-plugin 的节点在 Egern 里无法表达,只能丢掉插件按明文 SS 输出
|
||||
-- (丢参数、保留节点 —— 与 surge_line 对多值 alpn 的处置同一约定)。
|
||||
-- 参数不全(mode 不是 http/tls)时同样整个不输出:宁可不要混淆,
|
||||
-- 也不能写出一个客户端认不出的 obfs 值。
|
||||
local function add_ss_obfs(lines, n)
|
||||
local name, _, opts = util.parse_sip003_plugin(n.plugin)
|
||||
if not name then return end
|
||||
if name ~= "obfs-local" and name ~= "simple-obfs" and name ~= "obfs" then return end
|
||||
local mode = opts.obfs or opts.mode
|
||||
if mode ~= "tls" and mode ~= "http" then return end
|
||||
kv(lines, P, "obfs", mode)
|
||||
kv(lines, P, "obfs_host", opts["obfs-host"] or opts.host)
|
||||
kv(lines, P, "obfs_uri", opts["obfs-uri"] or opts.uri)
|
||||
end
|
||||
|
||||
-- 单个节点 → YAML 片段。协议不在 Egern 的清单里时返回 nil,由调用方整条剔除。
|
||||
local function format_node(n, name)
|
||||
local key = EGERN_KEY[(n.proto or ""):lower()]
|
||||
if not key then return nil end
|
||||
|
||||
local lines = {}
|
||||
lines[#lines + 1] = " - " .. key .. ":"
|
||||
kv(lines, P, "name", name)
|
||||
kv(lines, P, "server", n.server)
|
||||
-- 端口必须是数字:Egern 的 port 是 integer,`port: abc` 这类非数字值
|
||||
-- 会被拒绝。与 clash_meta 的 `tonumber() or 0` 兜底对齐。
|
||||
lines[#lines + 1] = P .. "port: " .. tostring(tonumber(n.port) or 0)
|
||||
|
||||
if key == "shadowsocks" then
|
||||
kv(lines, P, "method", n.method or n.cipher or "aes-256-gcm")
|
||||
kv(lines, P, "password", n.password)
|
||||
add_ss_obfs(lines, n)
|
||||
|
||||
elseif key == "vmess" then
|
||||
kv(lines, P, "user_id", n.uuid)
|
||||
-- security 的取值域(auto / aes-128-gcm / chacha20-poly1305 / none / zero)
|
||||
-- 与 node.VMESS_CIPHERS 完全一致,不需要像 Loon 那样映射拼写;
|
||||
-- 缺省按输出端的既有约定回退到 auto。
|
||||
kv(lines, P, "security", n.cipher or "auto")
|
||||
if n.legacy ~= nil then kv(lines, P, "legacy", yaml_bool(n.legacy)) end
|
||||
transport_block(lines, n)
|
||||
|
||||
elseif key == "vless" then
|
||||
kv(lines, P, "user_id", n.uuid)
|
||||
-- flow 是 XTLS Vision 的必需参数,缺了服务端要求 vision 时握手失败
|
||||
kv(lines, P, "flow", n.flow)
|
||||
transport_block(lines, n)
|
||||
|
||||
elseif key == "trojan" then
|
||||
kv(lines, P, "password", n.password)
|
||||
kv(lines, P, "sni", n.sni or n.servername)
|
||||
kv(lines, P, "skip_tls_verify", yaml_bool(n["skip-cert-verify"]))
|
||||
reality_block(lines, P, n)
|
||||
-- Trojan 的 WebSocket 是**独立**的 websocket 子对象(path / host),
|
||||
-- 不在 transport 里 —— 与 vmess / vless 的写法不同。
|
||||
if (n.net or n.network or ""):lower() == "ws" then
|
||||
lines[#lines + 1] = P .. "websocket:"
|
||||
kv(lines, P .. " ", "path", n.path)
|
||||
kv(lines, P .. " ", "host", n.host)
|
||||
end
|
||||
|
||||
elseif key == "anytls" then
|
||||
-- AnyTLS 建立在 TLS 之上,没有明文模式,也就没有 TLS 开关;
|
||||
-- 密码字段名是 password(Hysteria2 才叫 auth)。
|
||||
kv(lines, P, "password", n.password)
|
||||
kv(lines, P, "sni", n.sni or n.servername)
|
||||
kv(lines, P, "skip_tls_verify", yaml_bool(n["skip-cert-verify"]))
|
||||
reality_block(lines, P, n)
|
||||
|
||||
elseif key == "hysteria2" then
|
||||
-- 密码在 Hysteria2 里叫 auth(不是 password)
|
||||
kv(lines, P, "auth", n.password)
|
||||
kv(lines, P, "sni", n.sni)
|
||||
if n.obfs and n.obfs ~= "" and n.obfs ~= "plain" then
|
||||
kv(lines, P, "obfs", n.obfs)
|
||||
kv(lines, P, "obfs_password", n["obfs-password"] or n.obfs_password)
|
||||
end
|
||||
kv(lines, P, "skip_tls_verify", yaml_bool(n["skip-cert-verify"]))
|
||||
|
||||
elseif key == "tuic" then
|
||||
kv(lines, P, "uuid", n.uuid)
|
||||
kv(lines, P, "password", n.password)
|
||||
kv(lines, P, "udp_relay_mode", n["udp-relay-mode"])
|
||||
kv_list(lines, P, "alpn", n.alpn)
|
||||
kv(lines, P, "sni", n.sni)
|
||||
kv(lines, P, "skip_tls_verify", yaml_bool(n["skip-cert-verify"]))
|
||||
|
||||
elseif key == "socks5" then
|
||||
kv(lines, P, "username", n.username)
|
||||
kv(lines, P, "password", n.password)
|
||||
|
||||
elseif key == "http" then
|
||||
kv(lines, P, "username", n.username)
|
||||
kv(lines, P, "password", n.password)
|
||||
|
||||
elseif key == "wireguard" then
|
||||
-- Egern 的 WireGuard 是**必填** private_key + peer_public_key,且
|
||||
-- local_ipv4 / local_ipv6 至少要有一个(官方原文)。统一模型里
|
||||
-- public-key 对 wireguard 而言就是对端公钥(与 Reality 无关),
|
||||
-- 与 clash_meta 的取值口径一致。
|
||||
kv(lines, P, "private_key", n["private-key"])
|
||||
kv(lines, P, "peer_public_key", n["public-key"] or n["peer-public-key"])
|
||||
kv(lines, P, "preshared_key", n["pre-shared-key"] or n["preshared-key"])
|
||||
kv(lines, P, "local_ipv4", n.ip)
|
||||
kv(lines, P, "local_ipv6", n.ipv6)
|
||||
kv_list(lines, P, "reserved", n.reserved)
|
||||
kv_list(lines, P, "dns_servers", n.dns)
|
||||
kv(lines, P, "mtu", n.mtu)
|
||||
kv(lines, P, "keepalive", n["persistent-keepalive"])
|
||||
end
|
||||
|
||||
if UDP_RELAY[key] then
|
||||
kv(lines, P, "udp_relay", yaml_bool(n.udp))
|
||||
end
|
||||
|
||||
return table.concat(lines, "\n")
|
||||
end
|
||||
|
||||
-- nodes → Egern 配置(YAML)
|
||||
function M.generate(nodes, options)
|
||||
options = options or {}
|
||||
nodes = nodes or {}
|
||||
local group_name = options.name or "PROXY"
|
||||
|
||||
-- Egern 要求节点名全局唯一(官方文档原文 "must be globally unique"),
|
||||
-- 与 mihomo 同一约束,所以复用 util.unique_tags —— 两个输出端的改名规则
|
||||
-- 保持一致。组名与内置策略(DIRECT / REJECT / PROXY)一并占位,
|
||||
-- 否则节点名撞上内置策略时引用会指向内置项。
|
||||
local tags = util.unique_tags(nodes, {
|
||||
[group_name] = true,
|
||||
DIRECT = true,
|
||||
REJECT = true,
|
||||
PROXY = true,
|
||||
})
|
||||
|
||||
local blocks, names = {}, {}
|
||||
for i, n in ipairs(nodes) do
|
||||
local block = format_node(n, tags[i])
|
||||
if block then
|
||||
blocks[#blocks + 1] = block
|
||||
names[#names + 1] = tags[i]
|
||||
end
|
||||
end
|
||||
|
||||
local out = {}
|
||||
if #blocks == 0 then
|
||||
-- 空列表写 `proxies: []`:只写 `proxies:` 的话 YAML 里它是 null,
|
||||
-- 不是「空列表」。与 clash_meta 的处置一致。
|
||||
out[#out + 1] = "proxies: []"
|
||||
else
|
||||
out[#out + 1] = "proxies:"
|
||||
for _, b in ipairs(blocks) do out[#out + 1] = b end
|
||||
end
|
||||
|
||||
out[#out + 1] = "policy_groups:"
|
||||
out[#out + 1] = " - select:"
|
||||
out[#out + 1] = " name: " .. esc_yaml(group_name)
|
||||
out[#out + 1] = " policies:"
|
||||
if #names > 0 then
|
||||
for _, nm in ipairs(names) do
|
||||
out[#out + 1] = " - " .. esc_yaml(nm)
|
||||
end
|
||||
else
|
||||
-- 没有节点时也要给出一个合法成员:空的 policies 列表在 Egern 里
|
||||
-- 没有可选项(与 clash_meta 兜底 REJECT 同一约定,Egern 的内置直连
|
||||
-- 策略是 DIRECT)。
|
||||
out[#out + 1] = " - DIRECT"
|
||||
end
|
||||
|
||||
return table.concat(out, "\n")
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -1,8 +1,10 @@
|
||||
-- output_formats.lua — Surge 系 / Loon / QX / Egern / Stash / Plain JSON 输出(纯 Lua)
|
||||
-- output_formats.lua — Surge 系 / Loon / QX / Stash / Plain JSON 输出(纯 Lua)
|
||||
-- (Egern 不在这里:它的配置是 YAML,见 output_egern.lua)
|
||||
-- luci-app-substore
|
||||
|
||||
local util = require("substore.util")
|
||||
local clash_meta = require("substore.output_clash_meta")
|
||||
local msg = require("substore.msg")
|
||||
|
||||
local M = {}
|
||||
|
||||
@@ -13,6 +15,24 @@ local function props(n)
|
||||
return proto
|
||||
end
|
||||
|
||||
-- Loon 的 VMess「加密方式」(位置参数的第 1 个)取值,取自
|
||||
-- nsloon.app/docs/Node/ 的 VMess 一行:none / auto / aes-128-cfb /
|
||||
-- aes-128-gcm / chacha20-ietf-poly1305。
|
||||
--
|
||||
-- 与统一模型的 VMESS_CIPHERS(node.lua:auto / none / zero / aes-128-gcm /
|
||||
-- chacha20-poly1305)有两处对不上,直接照抄会让节点在 Loon 上加载失败:
|
||||
-- * 同一个算法两种拼写:模型写 chacha20-poly1305,Loon 写 chacha20-ietf-poly1305;
|
||||
-- * 模型的 zero 是 Xray 专用的取值,Loon 的清单里没有。
|
||||
-- 所以做一次显式映射,未列出的取值退回 auto(Loon 与各家都认的默认值)。
|
||||
-- 只用于 Loon;Surge / Surfboard / SurgeMac 的 vmess 走具名 username=,
|
||||
-- 加密方式是另一个参数(encrypt-method),见 LEGACY_ISSUES 的 7.9。
|
||||
local LOON_VMESS_CIPHER = {
|
||||
auto = "auto", none = "none",
|
||||
["aes-128-gcm"] = "aes-128-gcm", ["aes-128-cfb"] = "aes-128-cfb",
|
||||
["chacha20-poly1305"] = "chacha20-ietf-poly1305",
|
||||
["chacha20-ietf-poly1305"] = "chacha20-ietf-poly1305",
|
||||
}
|
||||
|
||||
-- 支持 Reality 的目标格式。Reality 只在 Loon 的节点行里有官方文档:
|
||||
-- nsloon.app/docs/Node/ 给出 VLESS / VMess / Trojan / AnyTLS 的 Reality 示例,
|
||||
-- 统一用 public-key / short-id 两个具名参数。
|
||||
@@ -28,11 +48,37 @@ end
|
||||
-- 与 LEGACY_ISSUES 里写的「会拒绝加载整份配置」未经证实,已按此更正)。
|
||||
-- 所以这里的判据是「不写客户端读不懂的东西」,与本文件丢弃 wireguard / ssr 同一约定,
|
||||
-- 不依赖任何未经证实的「整份配置会被拒绝」前提。
|
||||
-- Egern 确实有 Reality,但它的写法是 YAML 里的 reality 对象
|
||||
-- (egernapp.com/docs/configuration/proxies/),与本文件输出的逗号行对不上。
|
||||
-- Egern 确实有 Reality,但它的写法是 YAML 里的 reality 对象,与本文件输出的逗号行
|
||||
-- 对不上 —— 那部分由 output_egern.lua 实现(7.2 已实施)。
|
||||
local REALITY_FLAVORS = { loon = true }
|
||||
|
||||
-- 生成 Surge 风格代理行(Surge / Surfboard / SurgeMac / Loon / Egern 通用)。
|
||||
-- 各 Surge 系客户端对「非通用协议」的支持情况。
|
||||
--
|
||||
-- 通用协议(shadowsocks / vmess / trojan / socks5 / http / hysteria2 / tuic /
|
||||
-- anytls)各家都认;各家**不一致**的只有 VLESS 与 SSR 两个,所以只记这两个。
|
||||
-- 用一张表而不是给每个调用点传布尔参数:此前是 `supports_ssr` 一个布尔量,
|
||||
-- 加一个维度就要再加一个参数,调用点一多必然漏传(漏传的默认值还是「支持」,
|
||||
-- 于是导出里多出客户端读不懂的行,且不报错)。
|
||||
--
|
||||
-- 依据(均为各客户端官方文档的协议清单):
|
||||
-- * Surge / SurgeMac:manual.nssurge.com 的 Proxy Protocols 一节只列
|
||||
-- HTTP and HTTP/2、SOCKS5、Shadowsocks、Snell、VMess、Trojan、TUIC、
|
||||
-- Hysteria 2、MASQUE、AnyTLS、Trust Tunnel、SSH、WireGuard、Tailscale
|
||||
-- —— 没有 VLESS,也没有 ShadowsocksR。
|
||||
-- * Surfboard:getsurfboard.com 的 external-proxy 清单同样没有 VLESS / SSR。
|
||||
-- * Loon:nsloon.app/docs/Node/ 有独立的 VLESS 与 ShadowsocksR 两节,都支持。
|
||||
--
|
||||
-- Egern **不在**这张表里:它的配置是 YAML,已由 output_egern.lua 单独实现
|
||||
-- (7.2 已实施)。它的协议清单与 Surge 家族本就不同(有 VLESS 与 WireGuard,
|
||||
-- 没有 SSR 与 Hysteria v1),能力判定一并搬到了那个模块里。
|
||||
local FAMILY_CAPS = {
|
||||
surge = { vless = false, ssr = false },
|
||||
surfboard = { vless = false, ssr = false },
|
||||
surgemac = { vless = false, ssr = false },
|
||||
loon = { vless = true, ssr = true },
|
||||
}
|
||||
|
||||
-- 生成 Surge 风格代理行(Surge / Surfboard / SurgeMac / Loon 通用)。
|
||||
-- flavor 取目标格式名,只用于「各客户端写法确实不同」的参数 —— 目前是 AnyTLS
|
||||
-- 密码的位置与 Reality 是否支持;其余参数各家共用同一套 Surge 语法。
|
||||
function M.surge_line(n, flavor)
|
||||
@@ -45,6 +91,22 @@ function M.surge_line(n, flavor)
|
||||
local e = {}
|
||||
local tls = (n.security and n.security ~= "none") or (n.tls and n.tls ~= false and n.tls ~= "none")
|
||||
|
||||
-- Loon 的凭据写法:端口之后的**位置参数**且用双引号包起来
|
||||
-- Trojan = Trojan,h,p,"密码"
|
||||
-- VLESS = VLESS,h,p,"UUID"
|
||||
-- VMess = VMess,h,p,加密方式,"UUID"
|
||||
-- (nsloon.app/docs/Node/)。Surge / Surfboard / SurgeMac 沿用同一套语法的
|
||||
-- 具名写法(password= / username=),所以只在 flavor == "loon" 时分叉。
|
||||
--
|
||||
-- 返回 nil 表示这个值用带引号的位置参数表达不了:值里本身含双引号时,
|
||||
-- 包裹后的 `"pa"ss"` 该怎么切由客户端自行决定,没有文档依据 —— 调用方
|
||||
-- 据此整条丢弃该节点(与「值里含逗号」同一约定,见函数末尾的检查)。
|
||||
local function loon_positional(v)
|
||||
v = v or ""
|
||||
if v:find('"', 1, true) then return nil end
|
||||
return '"' .. v .. '"'
|
||||
end
|
||||
|
||||
-- ws 传输参数。vmess 与 vless 都要输出:这两个协议在 Surge 家族里共用同一套
|
||||
-- 参数名,此前只有 vmess 分支写了,vless + ws 的节点导出后丢掉整个传输层,
|
||||
-- 客户端按 tcp 去连一个只开了 ws 的端口,握手必然失败且不报错。
|
||||
@@ -68,7 +130,12 @@ function M.surge_line(n, flavor)
|
||||
-- 只有 Loon 的节点行文档化了 Reality 参数(见 REALITY_FLAVORS 的说明)
|
||||
if not REALITY_FLAVORS[flavor] then return end
|
||||
if not n["public-key"] then return end
|
||||
e[#e + 1] = "public-key=" .. n["public-key"]
|
||||
-- Loon 文档把公钥画成带双引号的值,short-id 不带:
|
||||
-- `public-key="LgJ9bNTyUqBLFkDA12-QgEL7c1yQ1ztk-V1Q-3OLXSk",short-id=164168844958a16d`
|
||||
-- (nsloon.app/docs/Node/ 的 VLESS / VMess / Trojan / AnyTLS Reality 示例)。
|
||||
-- Base64 字母表(A-Za-z0-9+/=)里没有双引号,加引号只是写法对齐,
|
||||
-- 不会引入歧义,也不会与下面的逗号检查冲突。
|
||||
e[#e + 1] = 'public-key="' .. n["public-key"] .. '"'
|
||||
if n["short-id"] then e[#e + 1] = "short-id=" .. n["short-id"] end
|
||||
end
|
||||
|
||||
@@ -76,7 +143,16 @@ function M.surge_line(n, flavor)
|
||||
e[#e + 1] = "encrypt-method=" .. (n.method or n.cipher or "aes-256-gcm")
|
||||
e[#e + 1] = "password=" .. (n.password or "")
|
||||
elseif proto == "vmess" then
|
||||
e[#e + 1] = "username=" .. (n.uuid or "")
|
||||
if flavor == "loon" then
|
||||
-- Loon 的 VMess 把加密方式与 UUID 都放在位置参数上(见 loon_positional)
|
||||
local id = loon_positional(n.uuid)
|
||||
if not id then return nil end
|
||||
-- 加密方式取值按 Loon 的拼写映射(见 LOON_VMESS_CIPHER)
|
||||
positional[#positional + 1] = LOON_VMESS_CIPHER[n.cipher] or "auto"
|
||||
positional[#positional + 1] = id
|
||||
else
|
||||
e[#e + 1] = "username=" .. (n.uuid or "")
|
||||
end
|
||||
if tls then e[#e + 1] = "tls=true" end
|
||||
if n.sni then e[#e + 1] = "sni=" .. n.sni end
|
||||
-- VMess 的 Reality 与 VLESS 同形。Loon 文档的 VMess-Reality 示例:
|
||||
@@ -88,14 +164,28 @@ function M.surge_line(n, flavor)
|
||||
add_reality()
|
||||
add_ws()
|
||||
elseif proto == "vless" then
|
||||
e[#e + 1] = "username=" .. (n.uuid or "")
|
||||
if flavor == "loon" then
|
||||
-- Loon 的 VLESS 只有一个位置参数:UUID(见 loon_positional)
|
||||
local id = loon_positional(n.uuid)
|
||||
if not id then return nil end
|
||||
positional[#positional + 1] = id
|
||||
else
|
||||
e[#e + 1] = "username=" .. (n.uuid or "")
|
||||
end
|
||||
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_reality()
|
||||
add_ws()
|
||||
elseif proto == "trojan" then
|
||||
e[#e + 1] = "password=" .. (n.password or "")
|
||||
if flavor == "loon" then
|
||||
-- Loon 的 Trojan 只有一个位置参数:密码(见 loon_positional)
|
||||
local pw = loon_positional(n.password)
|
||||
if not pw then return nil end
|
||||
positional[#positional + 1] = pw
|
||||
else
|
||||
e[#e + 1] = "password=" .. (n.password or "")
|
||||
end
|
||||
e[#e + 1] = "tls=true"
|
||||
if n.sni then e[#e + 1] = "sni=" .. n.sni end
|
||||
add_reality()
|
||||
@@ -112,7 +202,8 @@ function M.surge_line(n, flavor)
|
||||
-- (getsurfboard.com/docs/profile-format/proxy/external-proxy/anytls/)
|
||||
-- Loon:端口之后的位置参数,值用双引号包起来
|
||||
-- `Name = AnyTLS,host,port,"password",sni=...`(nsloon.app/docs/Node/)
|
||||
-- Egern 没有逗号行语法(配置是 YAML),这里沿用具名写法。
|
||||
-- 其余 flavor(surge / surgemac)走上面的具名写法。
|
||||
-- (Egern 已不在本文件里 —— 它的配置是 YAML,见 output_egern.lua。)
|
||||
--
|
||||
-- 这是本文件里唯一按 flavor 分叉的协议:trojan / vmess / vless 一律用具名
|
||||
-- 参数,而 Loon / Surfboard 的文档同样把它们的凭据画在位置参数上 —— 那是
|
||||
@@ -120,7 +211,9 @@ function M.surge_line(n, flavor)
|
||||
if flavor == "surfboard" then
|
||||
positional[#positional + 1] = n.password or ""
|
||||
elseif flavor == "loon" then
|
||||
positional[#positional + 1] = '"' .. (n.password or "") .. '"'
|
||||
local pw = loon_positional(n.password)
|
||||
if not pw then return nil end
|
||||
positional[#positional + 1] = pw
|
||||
else
|
||||
e[#e + 1] = "password=" .. (n.password or "")
|
||||
end
|
||||
@@ -185,8 +278,13 @@ function M.surge_line(n, flavor)
|
||||
local v = kv:match("^[^=]*=(.*)$")
|
||||
if v and v:find(",", 1, true) then return nil end
|
||||
end
|
||||
-- 位置参数同样没有引号 / 转义机制(Loon 的那对引号只是标记,值里的逗号照样
|
||||
-- 会被当成分隔符),判定条件与具名参数一致
|
||||
-- 位置参数(Loon 的凭据写法)按官方文档是带双引号包裹的,而 Loon 文档明说
|
||||
-- 「参数值中含有英文逗号时,请使用双引号包裹」(nsloon.app/docs/Node/)——
|
||||
-- 也就是说那对引号**确实**能保住逗号。这里仍然按「值里含逗号就整条丢弃」
|
||||
-- 处理,是刻意的保守选择:本行的具名参数(sni= / ws-path= / ws-headers=Host:)
|
||||
-- 一律不带引号,同一行里两种约定混用会让「哪个值被包裹」变成依赖客户端实现
|
||||
-- 的行为;统一丢弃最容易验证,代价只是极少见的「密码里带逗号」的节点。
|
||||
-- (值里本身带双引号的情况已由 loon_positional 在构造时挡掉。)
|
||||
for _, v in ipairs(positional) do
|
||||
if v:find(",", 1, true) then return nil end
|
||||
end
|
||||
@@ -219,20 +317,23 @@ local function names_of(nodes)
|
||||
return out
|
||||
end
|
||||
|
||||
-- Surge 家族配置(Surge / Surfboard / SurgeMac / Loon / Egern 通用)
|
||||
-- supports_ssr:Loon / Egern 支持 SSR;Surge / Surfboard / SurgeMac 不支持,跳过 ssr 节点
|
||||
-- flavor:目标格式名,原样交给 surge_line,用于按客户端分叉的少数参数
|
||||
local function surge_config(nodes, group_name, supports_ssr, flavor)
|
||||
-- Surge 家族配置(Surge / Surfboard / SurgeMac / Loon 通用)
|
||||
-- flavor:目标格式名。既决定 surge_line 里按客户端分叉的少数参数,也用来查
|
||||
-- FAMILY_CAPS —— 每个调用点不再各自传「支持什么」的布尔量(见 FAMILY_CAPS 的说明)。
|
||||
local function surge_config(nodes, group_name, flavor)
|
||||
local caps = FAMILY_CAPS[flavor] or {}
|
||||
local list = nodes or {}
|
||||
-- 过滤 Surge 家族无法用单行 [Proxy] 表达的协议:
|
||||
-- ssr:仅 Loon / Egern 支持(supports_ssr),Surge/Surfboard/SurgeMac 丢
|
||||
-- vless / ssr:按 FAMILY_CAPS 分客户端(Surge/Surfboard/SurgeMac 两个都不认)
|
||||
-- wireguard:Surge 需专用多段 [WireGuard] 配置,单行无法表达,统一丢弃(不输出损坏行)
|
||||
local kept = {}
|
||||
for _, n in ipairs(list) do
|
||||
local p = (n.proto or ""):lower()
|
||||
if p == "wireguard" then
|
||||
-- drop
|
||||
elseif not supports_ssr and p == "ssr" then
|
||||
elseif p == "ssr" and not caps.ssr then
|
||||
-- drop
|
||||
elseif p == "vless" and not caps.vless then
|
||||
-- drop
|
||||
else
|
||||
kept[#kept + 1] = n
|
||||
@@ -263,29 +364,29 @@ end
|
||||
|
||||
function M.to_surge(nodes, options)
|
||||
options = options or {}
|
||||
return surge_config(nodes, options.name or "PROXY", false, "surge")
|
||||
return surge_config(nodes, options.name or "PROXY", "surge")
|
||||
end
|
||||
|
||||
function M.to_surfboard(nodes, options)
|
||||
options = options or {}
|
||||
return surge_config(nodes, options.name or "PROXY", false, "surfboard")
|
||||
return surge_config(nodes, options.name or "PROXY", "surfboard")
|
||||
end
|
||||
|
||||
function M.to_surgemac(nodes, options)
|
||||
options = options or {}
|
||||
return surge_config(nodes, options.name or "PROXY", false, "surgemac")
|
||||
return surge_config(nodes, options.name or "PROXY", "surgemac")
|
||||
end
|
||||
|
||||
-- Loon / Egern:Surge 兼容语法(支持 SSR)
|
||||
-- Loon:Surge 兼容语法(支持 VLESS 与 SSR)
|
||||
function M.to_loon(nodes, options)
|
||||
options = options or {}
|
||||
return surge_config(nodes, options.name or "PROXY", true, "loon")
|
||||
return surge_config(nodes, options.name or "PROXY", "loon")
|
||||
end
|
||||
|
||||
function M.to_egern(nodes, options)
|
||||
options = options or {}
|
||||
return surge_config(nodes, options.name or "PROXY", true, "egern")
|
||||
end
|
||||
-- Egern 不在本文件里:它的配置是 YAML,而不是这里的逗号行,所以由
|
||||
-- output_egern.lua 单独实现(LEGACY_ISSUES 的 7.2 已实施)。
|
||||
-- 此前这里是 `surge_config(nodes, name, "egern")` —— 导出的是 Surge 的逗号行,
|
||||
-- Egern 根本读不了。
|
||||
|
||||
-- Stash:Clash 兼容 YAML
|
||||
function M.to_stash(nodes, options)
|
||||
@@ -324,21 +425,53 @@ local function qx_transport(n, f)
|
||||
end
|
||||
end
|
||||
|
||||
-- 注意:带 Reality 公钥的 vmess / vless 不走这里,改走 qx_obfs_reality
|
||||
-- (QX 只认 obfs= 形式的 TLS 标志,见该函数的说明)。
|
||||
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
|
||||
|
||||
-- QX 的 Reality 参数。官方 sample.conf(crossutility/Quantumult-X 仓库)的
|
||||
-- [server_local] 里,vmess / vless / trojan / anytls 四类条目都用这两个键:
|
||||
-- Base64 公钥 reality-base64-pubkey 与十六进制 reality-hex-shortid。
|
||||
-- sample.conf 的注释写明「只要该行设置了 reality-base64-pubkey,标准 TLS 就会被
|
||||
-- 换成 Reality TLS」。判据取「有没有 public-key」,与 surge_line 的 add_reality 一致。
|
||||
-- QX 的 vmess / vless **带 Reality 公钥**时的传输 / TLS 标志。
|
||||
--
|
||||
-- 注意(已知差异,见 CHANGELOG):sample.conf 的注释把「该行带 TLS 标志」列为
|
||||
-- 生效前提,而 vmess / vless 的 TLS 标志在 QX 里写作 obfs=over-tls / obfs=wss
|
||||
-- (trojan / anytls 才写 over-tls=true + tls-host=)。本函数只负责输出 Reality
|
||||
-- 两个键,TLS 标志由上面的 qx_tls 输出;两者写法不一致时 QX 会忽略公钥。
|
||||
-- 官方 sample.conf(crossutility/Quantumult-X 仓库)的 [server_local] 前写明:
|
||||
--
|
||||
-- …if the corresponding line (socks5: over-tls=true, http: over-tls=true,
|
||||
-- trojan: over-tls=true or obfs=wss, anytls: over-tls=true, … vmess:
|
||||
-- obfs=over-tls or obfs=wss, vless: obfs=over-tls or obfs=wss) contains the
|
||||
-- reality-base64-pubkey param, then the standard TLS will be replaced with
|
||||
-- the Reality.
|
||||
--
|
||||
-- 即 vmess / vless 的「TLS 标志」在 QX 里是 obfs= 形式,而 qx_tls() 对所有协议
|
||||
-- 统一输出 tls-host= + tls-verification=。两者写法不一致时 QX 会忽略公钥,
|
||||
-- 节点静默退回普通 TLS(握手必失败)。所以带公钥的 vmess / vless 改走这里,
|
||||
-- 不带公钥的一律不动 —— qx_tls() 是既有实现,全量改写会让所有 QX vmess / vless
|
||||
-- TLS 节点的输出形态变化(`tls-host` / `tls-verification` 对 vmess / vless 是否
|
||||
-- 同样有效没有官方依据:sample.conf 里只用 obfs= 形式,但「未出现」≠「无效」)。
|
||||
--
|
||||
-- obfs 取值与 qx_transport 对齐:ws + TLS 写 wss,纯 TLS 写 over-tls。
|
||||
-- wss 下 obfs-host 同时充当 TLS 握手名与 Host 头(sample.conf 的注释),
|
||||
-- 所以 host 优先、退回 sni;over-tls 下 obfs-host 只是 SNI。
|
||||
local function qx_obfs_reality(n, f)
|
||||
if n.net == "ws" then
|
||||
f[#f + 1] = "obfs=wss"
|
||||
if n.path then f[#f + 1] = "obfs-uri=" .. n.path end
|
||||
local h = n.host or n.sni
|
||||
if h then f[#f + 1] = "obfs-host=" .. h end
|
||||
else
|
||||
f[#f + 1] = "obfs=over-tls"
|
||||
if n.sni then f[#f + 1] = "obfs-host=" .. n.sni end
|
||||
end
|
||||
end
|
||||
|
||||
-- QX 的 Reality 参数。官方 sample.conf 的 [server_local] 里,vmess / vless /
|
||||
-- trojan / anytls 四类条目都用这两个键:Base64 公钥 reality-base64-pubkey 与
|
||||
-- 十六进制 reality-hex-shortid。sample.conf 的注释写明「只要该行设置了
|
||||
-- reality-base64-pubkey,标准 TLS 就会被换成 Reality TLS」。判据取「有没有
|
||||
-- public-key」,与 surge_line 的 add_reality 一致。
|
||||
--
|
||||
-- TLS 标志由调用方负责:vmess / vless 走 qx_obfs_reality(obfs=over-tls / wss),
|
||||
-- trojan / anytls 走 over-tls=true + tls-host=。
|
||||
local function qx_reality(n, f)
|
||||
if not n["public-key"] then return end
|
||||
f[#f + 1] = "reality-base64-pubkey=" .. n["public-key"]
|
||||
@@ -362,16 +495,16 @@ local function qx_server_line(n, tag)
|
||||
elseif proto == "vmess" then
|
||||
f[#f + 1] = "method=none"
|
||||
f[#f + 1] = "password=" .. (n.uuid or "")
|
||||
qx_transport(n, f)
|
||||
qx_tls(n, f)
|
||||
-- 带 Reality 公钥的 vmess 必须用 obfs= 形式的 TLS 标志,否则 QX 忽略公钥
|
||||
-- (见 qx_obfs_reality 的说明)。不带公钥的维持既有写法。
|
||||
if n["public-key"] then qx_obfs_reality(n, f) else qx_transport(n, f); qx_tls(n, f) end
|
||||
-- sample.conf 有 vmess 的 Reality 条目(obfs=over-tls + reality-base64-pubkey
|
||||
-- + reality-hex-shortid),此前只给 vless / anytls 输出了公钥。
|
||||
qx_reality(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)
|
||||
if n["public-key"] then qx_obfs_reality(n, f) else qx_transport(n, f); qx_tls(n, f) end
|
||||
-- sample.conf 的 vless Reality 条目在公钥之外还带 vless-flow=xtls-rprx-vision
|
||||
if n["public-key"] and n.flow then f[#f + 1] = "vless-flow=" .. n.flow end
|
||||
qx_reality(n, f)
|
||||
@@ -454,12 +587,12 @@ function M.generate(nodes, format, options)
|
||||
if format == "surfboard" then return M.to_surfboard(nodes, options) end
|
||||
if format == "surgemac" then return M.to_surgemac(nodes, options) end
|
||||
if format == "loon" then return M.to_loon(nodes, options) end
|
||||
if format == "egern" then return M.to_egern(nodes, options) end
|
||||
-- egern 不在这里:见文件头的说明,它由 output_egern.lua 实现
|
||||
if format == "qx" then return M.to_qx(nodes, options) end
|
||||
if format == "stash" then return M.to_stash(nodes, options) end
|
||||
if format == "clash" then return M.to_clash(nodes, options) end
|
||||
if format == "plain" then return M.to_plain(nodes) end
|
||||
return nil, "unsupported format: " .. tostring(format)
|
||||
return nil, msg.join("Unsupported output format: ", tostring(format))
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -3,6 +3,7 @@
|
||||
-- 与 parser.parse_wireguard_conf 互为逆操作:导入解析 [Interface] / [Peer],此处按同格式写回。
|
||||
|
||||
local util = require("substore.util")
|
||||
local msg = require("substore.msg")
|
||||
|
||||
local M = {}
|
||||
|
||||
@@ -140,12 +141,11 @@ function M.generate(nodes, options)
|
||||
end
|
||||
if #wg == 0 then
|
||||
-- 明确报错优于返回空文件:用户能知道是"没有 WireGuard 节点"而非订阅坏了
|
||||
return nil, "没有可导出的 WireGuard 节点"
|
||||
return nil, "No WireGuard node to export"
|
||||
end
|
||||
if #wg > 1 then
|
||||
return nil, string.format(
|
||||
"WireGuard .conf 每个文件只能包含一条隧道,当前有 %d 个 WireGuard 节点;请只导出单个节点",
|
||||
#wg)
|
||||
return nil, msg.compose("A WireGuard .conf file can hold only one tunnel, but there are ", #wg,
|
||||
" WireGuard nodes; export a single node instead")
|
||||
end
|
||||
|
||||
local n = wg[1]
|
||||
@@ -155,7 +155,7 @@ function M.generate(nodes, options)
|
||||
-- 客户端更是无法导入。宁可不导出,也不要产出这种「看起来成功」的残缺文件。
|
||||
local priv = n["private-key"] or n.private_key
|
||||
if priv == nil or priv == "" then
|
||||
return nil, "该 WireGuard 节点没有私钥 (private-key),无法导出 .conf"
|
||||
return nil, "This WireGuard node has no private key (private-key), cannot export .conf"
|
||||
end
|
||||
|
||||
-- 注释行也必须压成单行:节点名来自订阅(不可信),含换行时会注入出真正的
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
local util = require("substore.util")
|
||||
local node = require("substore.node")
|
||||
local msg = require("substore.msg")
|
||||
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")
|
||||
@@ -101,11 +102,11 @@ end
|
||||
|
||||
-- 「混合格式」提示里各格式的显示名
|
||||
local FORMAT_LABELS = {
|
||||
uri = "URI 链接",
|
||||
uri = "URI link",
|
||||
json = "JSON",
|
||||
yaml = "Clash YAML",
|
||||
["wireguard-conf"] = "WireGuard .conf",
|
||||
surge = "Surge/Loon 配置",
|
||||
surge = "Surge/Loon config",
|
||||
}
|
||||
|
||||
-- 行首锚定,数出「整行就是一条节点链接」的行数。
|
||||
@@ -801,7 +802,9 @@ local function parse_wireguard_conf(content)
|
||||
end
|
||||
end
|
||||
|
||||
if #peers == 0 then return nil, "bad wireguard conf: no [Peer]" end
|
||||
-- 这条会一路传到用户(M.parse 里 `if not nodes then return nil, err end`),
|
||||
-- 所以写成完整的英文 msgid,而不是 "bad wireguard conf: …" 那种内部诊断口吻。
|
||||
if #peers == 0 then return nil, "A WireGuard .conf file has no [Peer] section" end
|
||||
|
||||
-- [Interface] 由各节点共享
|
||||
local common = {
|
||||
@@ -900,7 +903,7 @@ local function parse_wireguard_conf(content)
|
||||
-- 一个可用 [Peer] 都没有时报错。此处 #peers > 0 已由上面保证,走到这里说明
|
||||
-- 每个 [Peer] 都缺 Endpoint。不能返回空列表:空列表会被上层当成「解析成功但
|
||||
-- 0 节点」,用户看到更新成功却一个节点都没有(H4/H5 的静默失败形态)。
|
||||
if #nodes == 0 then return nil, "bad wireguard conf: no usable [Peer] endpoint" end
|
||||
if #nodes == 0 then return nil, "A WireGuard .conf file has no [Peer] with a usable Endpoint" end
|
||||
|
||||
return nodes
|
||||
end
|
||||
@@ -940,7 +943,7 @@ end
|
||||
|
||||
local function parse_json_content(content)
|
||||
local data = util.json_decode(content)
|
||||
if type(data) ~= "table" then return nil, "JSON 解析失败" end
|
||||
if type(data) ~= "table" then return nil, "Failed to parse JSON" end
|
||||
|
||||
-- 客户端配置文件分发:sing-box / V2Ray / Clash JSON
|
||||
if type(data.outbounds) == "table" then
|
||||
@@ -1545,7 +1548,7 @@ function M.parse(content)
|
||||
elseif format == "base64" then
|
||||
-- 兼容标准 base64 与 base64url(- _ 无 padding):base64_url_decode 两者皆可
|
||||
local decoded = util.base64_url_decode(content)
|
||||
if decoded == "" then return nil, "Base64 解码失败" end
|
||||
if decoded == "" then return nil, "Base64 decoding failed" end
|
||||
-- 解出来的内容本身可能是 YAML / JSON:机场把整份 Clash 配置或 sing-box
|
||||
-- 配置 base64 后直接下发是很常见的做法。原先一律按 URI 列表解析,这类
|
||||
-- 订阅会得到 0 个节点且不报错(用户只看到「订阅为空」)。
|
||||
@@ -1573,7 +1576,7 @@ function M.parse(content)
|
||||
if not nodes then return nil, err end
|
||||
return finish(nodes, "wireguard-conf")
|
||||
end
|
||||
return nil, "无法识别的订阅格式"
|
||||
return nil, "Unrecognized subscription format"
|
||||
end
|
||||
|
||||
-- 本地订阅解析:支持文本模式和表单模式
|
||||
@@ -1583,7 +1586,7 @@ function M.parse_local(content, mode)
|
||||
if mode == "form" then
|
||||
-- 表单模式:content 为 JSON 数组
|
||||
local data = util.json_decode(content)
|
||||
if type(data) ~= "table" then return nil, "表单数据解析失败" end
|
||||
if type(data) ~= "table" then return nil, "Failed to parse form data" end
|
||||
local node_mod = require("substore.node")
|
||||
local nodes = {}
|
||||
for _, item in ipairs(data) do
|
||||
@@ -1649,7 +1652,7 @@ function M.parse_local(content, mode)
|
||||
else
|
||||
local opt = util.json_decode(s)
|
||||
if type(opt) ~= "table" then
|
||||
return nil, "AmneziaWG 参数必须是合法的 JSON 对象"
|
||||
return nil, "The AmneziaWG options must be a valid JSON object"
|
||||
end
|
||||
n["amnezia-wg-option"] = opt
|
||||
end
|
||||
@@ -1674,8 +1677,9 @@ function M.parse_local(content, mode)
|
||||
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, " + ") ..
|
||||
"):一次只能导入一种格式,请拆开后分次导入"
|
||||
return nil, msg.compose("Mixed formats in one text (",
|
||||
msg.compose_list(names, " + "),
|
||||
"): only one format can be imported at a time; split the text and import the parts separately")
|
||||
end
|
||||
return M.parse(content)
|
||||
end
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
-- util.lua — 通用工具(纯 Lua 5.1,无外部依赖)
|
||||
-- luci-app-substore
|
||||
|
||||
local msg = require("substore.msg")
|
||||
|
||||
local M = {}
|
||||
|
||||
local B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
|
||||
@@ -654,17 +656,23 @@ function M.human_bytes(n)
|
||||
return s .. units[i]
|
||||
end
|
||||
|
||||
-- 秒数 → 可读时长(天/小时/分钟),如 10天、8小时
|
||||
-- 秒数 → 可读时长(天/小时/分钟)。
|
||||
--
|
||||
-- 返回的是**语言中立的组合消息**(数字 + 单位 msgid),由显示边界逐段翻译
|
||||
-- (见 msg.lua)。本模块不能 require("luci.i18n"):substore-cron.sh 会在没有
|
||||
-- LuCI 环境的独立 lua 进程里跑 core.sync。
|
||||
-- 单位 msgid 带前导空格:英文得到 "3 days",中文 msgstr 去掉那个空格即 "3天",
|
||||
-- 两边都不必为对方将就。
|
||||
function M.human_duration(secs)
|
||||
secs = tonumber(secs) or 0
|
||||
if secs <= 0 then return "已过期" end
|
||||
if secs <= 0 then return "Expired" end
|
||||
local d = secs / 86400
|
||||
if d >= 1 then return string.format("%d天", math.floor(d)) end
|
||||
if d >= 1 then return msg.compose(math.floor(d), " days") end
|
||||
local h = secs / 3600
|
||||
if h >= 1 then return string.format("%d小时", math.floor(h)) end
|
||||
if h >= 1 then return msg.compose(math.floor(h), " hours") end
|
||||
local m = secs / 60
|
||||
if m >= 1 then return string.format("%d分钟", math.floor(m)) end
|
||||
return "不足1分钟"
|
||||
if m >= 1 then return msg.compose(math.floor(m), " minutes") end
|
||||
return "Less than a minute"
|
||||
end
|
||||
|
||||
-- 为一组节点生成唯一 tag(供 sing-box / Xray 完整配置使用)。
|
||||
|
||||
Reference in new issue
Block a user