💐 Sync 2026-10-06 12:33:44

This commit is contained in:
github-actions[bot] committed 2026-10-06 12:33:44 +08:00
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 完整配置使用)。