mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-05 09:54:06 +08:00
🤞 Sync 2026-09-30 09:28:31
This commit is contained in:
1 parent
0108cfa10a
commit
3d9edb1c37
78 files changed
+4594
-1815
No files matched your search
@@ -133,7 +133,8 @@ var FIELD_LABELS = {
|
||||
"listen-port": "Listen Port",
|
||||
"mtu": "MTU",
|
||||
"dns": "DNS",
|
||||
"amnezia-wg-option": "Amnezia WG Option"
|
||||
"amnezia-wg-option": "Amnezia WG Option",
|
||||
"username": "Username"
|
||||
};
|
||||
var NODEFORM_I18N = {
|
||||
"node": "<%:Node%>",
|
||||
|
||||
@@ -76,7 +76,8 @@ var FIELD_LABELS = {
|
||||
"listen-port": "Listen Port",
|
||||
"mtu": "MTU",
|
||||
"dns": "DNS",
|
||||
"amnezia-wg-option": "Amnezia WG Option"
|
||||
"amnezia-wg-option": "Amnezia WG Option",
|
||||
"username": "Username"
|
||||
};
|
||||
var NODEFORM_I18N = {
|
||||
"node": "<%:Node%>",
|
||||
|
||||
@@ -7,7 +7,7 @@ local parser = require("substore.parser")
|
||||
|
||||
local M = {}
|
||||
|
||||
M.version = "2.5.1"
|
||||
M.version = "2.6.0"
|
||||
M.DATA_DIR = "/etc/substore"
|
||||
M.LIST_FILE = M.DATA_DIR .. "/subscriptions.json"
|
||||
M.NODES_DIR = M.DATA_DIR .. "/nodes"
|
||||
@@ -25,12 +25,21 @@ function M.ensure_dirs()
|
||||
util.ensure_dir(M.NODES_DIR)
|
||||
end
|
||||
|
||||
-- 读取订阅列表,返回 seq, items, err。
|
||||
--
|
||||
-- 必须区分「文件不存在 / 为空」(合法的空列表)与「文件存在但解析失败」
|
||||
-- (内容损坏、被截断、磁盘错误)。后者若按空列表处理,后果是灾难性的:
|
||||
-- M.add / M.add_local / M.add_combo 会在这个空表上追加一条然后整表写回,
|
||||
-- 把用户已有的全部订阅抹掉,并且 _seq 归零后重新发出 s00000001 这种
|
||||
-- 已经用过的 ID。因此损坏时必须显式报错,由调用方拒绝写入。
|
||||
local function load()
|
||||
M.ensure_dirs()
|
||||
local raw = util.read_file(M.LIST_FILE)
|
||||
if not raw or raw == "" then return 0, {} end
|
||||
local data = util.json_decode(raw)
|
||||
if type(data) ~= "table" then return 0, {} end
|
||||
if type(data) ~= "table" then
|
||||
return 0, {}, "订阅列表文件已损坏,无法解析:" .. M.LIST_FILE
|
||||
end
|
||||
local seq = tonumber(data._seq) or 0
|
||||
local items = type(data.items) == "table" and data.items or {}
|
||||
return seq, items
|
||||
@@ -41,8 +50,11 @@ local function save(seq, items)
|
||||
return util.atomic_write(M.LIST_FILE, util.json_encode({ _seq = seq, items = items }))
|
||||
end
|
||||
|
||||
-- 返回 arr, err。err 非空表示列表文件已损坏(此时 arr 为空)。
|
||||
-- 追加第二个返回值是向后兼容的:调用方普遍写成 ipairs(core.list()) 或
|
||||
-- local items = core.list(),都只取第一个值。
|
||||
function M.list()
|
||||
local _, items = load()
|
||||
local _, items, lerr = load()
|
||||
local arr = {}
|
||||
for id, meta in pairs(items) do
|
||||
local m = {}
|
||||
@@ -51,7 +63,7 @@ function M.list()
|
||||
arr[#arr + 1] = m
|
||||
end
|
||||
table.sort(arr, function(a, b) return (a.name or "") < (b.name or "") end)
|
||||
return arr
|
||||
return arr, lerr
|
||||
end
|
||||
|
||||
function M.get(id)
|
||||
@@ -70,7 +82,8 @@ function M.add(name, url, opts)
|
||||
url = util.trim(url or "")
|
||||
opts = opts or {}
|
||||
if name == "" or url == "" then return nil, "名称/URL 不能为空" end
|
||||
local seq, items = load()
|
||||
local seq, items, lerr = load()
|
||||
if lerr then return nil, lerr end
|
||||
seq = seq + 1
|
||||
local id = string.format("s%08x", seq)
|
||||
local cron_time = util.trim(opts.cron_time or "")
|
||||
@@ -103,7 +116,8 @@ function M.add_local(name, raw_content, local_mode, opts)
|
||||
local_mode = local_mode or "text"
|
||||
opts = opts or {}
|
||||
if name == "" or raw_content == "" then return nil, "名称/内容 不能为空" end
|
||||
local seq, items = load()
|
||||
local seq, items, lerr = load()
|
||||
if lerr then return nil, lerr end
|
||||
seq = seq + 1
|
||||
local id = string.format("s%08x", seq)
|
||||
items[id] = {
|
||||
@@ -131,7 +145,8 @@ end
|
||||
-- 获取订阅的下载 token;若不存在则生成并持久化
|
||||
function M.ensure_token(id)
|
||||
if not id_is_valid(id) then return nil end
|
||||
local seq, items = load()
|
||||
local seq, items, lerr = load()
|
||||
if lerr then return nil end
|
||||
local meta = items[id]
|
||||
if not meta then return nil end
|
||||
if not meta.token or meta.token == "" then
|
||||
@@ -178,7 +193,8 @@ 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
|
||||
local seq, items = load()
|
||||
local seq, items, lerr = load()
|
||||
if lerr then return false, lerr end
|
||||
local meta = items[id]
|
||||
if not meta then return false, "订阅不存在" end
|
||||
for k, v in pairs(patch or {}) do
|
||||
@@ -189,7 +205,8 @@ end
|
||||
|
||||
function M.remove(id)
|
||||
if not id_is_valid(id) then return false end
|
||||
local seq, items = load()
|
||||
local seq, items, lerr = load()
|
||||
if lerr then return false, lerr end
|
||||
if not items[id] then return false end
|
||||
items[id] = nil
|
||||
save(seq, items)
|
||||
@@ -363,6 +380,10 @@ local FORM_KEYS = {
|
||||
reserved = true, ["persistent-keepalive"] = true, persistent_keepalive = true,
|
||||
["listen-port"] = true, listen_port = true,
|
||||
mtu = true, dns = true, ["amnezia-wg-option"] = true,
|
||||
-- socks / http 的用户名。必须在表里:下面的 merge_form_node 先按 FORM_KEYS
|
||||
-- 清空原节点再套用提交值,不在表里的字段会保留旧值——用户在表单里清空用户名
|
||||
-- 也删不掉(collectNodes 会略过空值),等于改不动。
|
||||
username = true,
|
||||
}
|
||||
|
||||
-- 合并表单节点到原节点:表单字段整体替换(可清空),非表单字段保留
|
||||
@@ -441,7 +462,8 @@ function M.add_combo(name, sources, opts)
|
||||
end
|
||||
end
|
||||
if #srcs == 0 then return nil, "请选择至少一个订阅" end
|
||||
local seq, items = load()
|
||||
local seq, items, lerr = load()
|
||||
if lerr then return nil, lerr end
|
||||
seq = seq + 1
|
||||
local id = string.format("s%08x", seq)
|
||||
items[id] = {
|
||||
|
||||
@@ -37,6 +37,11 @@ end
|
||||
function M.normalize(node)
|
||||
if type(node) ~= "table" then return node end
|
||||
if node.proto == "ss" then node.proto = "shadowsocks" end
|
||||
-- socks5 与 socks 是同一个协议,只是各客户端写法不同(Clash 写 socks5、
|
||||
-- sing-box 写 socks、分享链接写 socks5://)。统一成 socks:
|
||||
-- 各输出模块本来就同时认这两种写法,但 node.filter / rules.proto_filter
|
||||
-- 是精确比较,两种写法会互相看不见——节点页按协议筛选和规则过滤都会漏。
|
||||
if node.proto == "socks5" then node.proto = "socks" end
|
||||
local d = DEFAULTS[node.proto] or {}
|
||||
for k, v in pairs(d) do
|
||||
if node[k] == nil then node[k] = v end
|
||||
|
||||
@@ -5,9 +5,15 @@ local M = {}
|
||||
|
||||
local function esc_yaml(s)
|
||||
s = tostring(s or "")
|
||||
s = s:gsub("\\", "\\\\"):gsub("\"", "\\\""):gsub("\n", "\\n")
|
||||
-- 注意:这里必须用 Lua 模式(不能传 plain=true),否则整串被当作字面量、永不匹配
|
||||
if s:find("[ :#{}%[%],&*?|>'\"%@`]") or s:match("^[-?]*:") then
|
||||
-- 是否需要双引号必须在转义之前判定:转义之后的 \t / \r 只剩「反斜杠 + 字母」,
|
||||
-- 落在 plain scalar 里会被 YAML 当成两个字面字符而不是制表符/回车。含任何控制
|
||||
-- 字符(\n \r \t 等)时必须加引号 —— 未加引号的换行/回车会直接破坏文档结构。
|
||||
local need_quote = s:find("%c") ~= nil
|
||||
-- 注意:这里必须用 Lua 模式(不能传 plain=true),否则整串被当作字面量、永不匹配
|
||||
or s:find("[ :#{}%[%],&*?|>'\"%@`]") ~= nil
|
||||
or s:match("^[-?]*:") ~= nil
|
||||
s = s:gsub("\\", "\\\\"):gsub("\"", "\\\""):gsub("\n", "\\n"):gsub("\r", "\\r"):gsub("\t", "\\t")
|
||||
if need_quote then
|
||||
return '"' .. s .. '"'
|
||||
end
|
||||
return s
|
||||
@@ -106,11 +112,18 @@ local function format_node(node)
|
||||
end
|
||||
|
||||
-- servername / sni
|
||||
if node.sni then
|
||||
lines[#lines + 1] = " servername: " .. esc_yaml(node.sni)
|
||||
end
|
||||
if node.servername then
|
||||
lines[#lines + 1] = " servername: " .. esc_yaml(node.servername)
|
||||
-- vmess / vless / trojan 在 mihomo 里用 servername;hysteria / hysteria2 用 sni
|
||||
-- (已对照上游文档确认:hysteria 系列没有 servername 字段,写了会被忽略,
|
||||
-- 结果 SNI 丢失 → 客户端拿 IP 校验证书直接握手失败)。hysteria 系列的 sni
|
||||
-- 由下面的协议专属分支输出。
|
||||
-- sni 与 servername 是同一个字段的两种写法,必须只输出一个键:Clash YAML 导入
|
||||
-- 会同时填上两者(先 sni = p.sni or p.servername,随后的字段保留循环又原样复制了
|
||||
-- servername),各写一行会让 YAML 出现重复键 —— 严格解析器直接报错,宽松解析器
|
||||
-- 则取最后一个,行为不确定。
|
||||
local is_hysteria = (ctype == "hysteria" or ctype == "hysteria2")
|
||||
local tls_name = node.sni or node.servername
|
||||
if tls_name and not is_hysteria then
|
||||
lines[#lines + 1] = " servername: " .. esc_yaml(tls_name)
|
||||
end
|
||||
|
||||
-- 特殊字段
|
||||
@@ -152,7 +165,7 @@ local function format_node(node)
|
||||
if node.sni then
|
||||
lines[#lines + 1] = " sni: " .. esc_yaml(node.sni)
|
||||
end
|
||||
-- 混淆(salamander)
|
||||
-- 混淆(salamander):hysteria2 的 obfs 是 { type, password } 两段
|
||||
if node.obfs and node.obfs ~= "" and node.obfs ~= "plain" then
|
||||
lines[#lines + 1] = " obfs: " .. esc_yaml(node.obfs)
|
||||
local opw = node["obfs-password"] or node.obfs_password
|
||||
@@ -162,6 +175,17 @@ local function format_node(node)
|
||||
end
|
||||
end
|
||||
|
||||
if ctype == "hysteria" then
|
||||
-- hysteria(v1):obfs 只是普通字符串,**没有** obfs-password
|
||||
-- (obfs-password 是 hysteria2 的 salamander 专属字段,写到 v1 上是非法键)
|
||||
if node.sni then
|
||||
lines[#lines + 1] = " sni: " .. esc_yaml(node.sni)
|
||||
end
|
||||
if node.obfs and node.obfs ~= "" and node.obfs ~= "plain" then
|
||||
lines[#lines + 1] = " obfs: " .. esc_yaml(node.obfs)
|
||||
end
|
||||
end
|
||||
|
||||
if ctype == "tuic" then
|
||||
if node["udp-relay-mode"] then
|
||||
lines[#lines + 1] = " udp-relay-mode: " .. esc_yaml(node["udp-relay-mode"])
|
||||
@@ -214,13 +238,13 @@ local function format_node(node)
|
||||
end
|
||||
end
|
||||
|
||||
if ctype == "socks5" then
|
||||
-- socks5 / http 的认证字段都是 username + password(已对照上游 mihomo 文档确认)。
|
||||
-- password 已由上面的「协议通用字段」输出,这里只补 username——重复输出会让
|
||||
-- YAML 里出现两个 password 键,属于非法/歧义配置。
|
||||
if ctype == "socks5" or ctype == "http" then
|
||||
if node.username then
|
||||
lines[#lines + 1] = " username: " .. esc_yaml(node.username)
|
||||
end
|
||||
if node.password then
|
||||
lines[#lines + 1] = " password: " .. esc_yaml(node.password)
|
||||
end
|
||||
end
|
||||
|
||||
if ctype == "ssr" then
|
||||
|
||||
@@ -57,8 +57,17 @@ function M.surge_line(n)
|
||||
e[#e + 1] = "username=" .. (n.uuid or "")
|
||||
if n.password then e[#e + 1] = "password=" .. n.password end
|
||||
if n.sni then e[#e + 1] = "sni=" .. n.sni end
|
||||
if n.alpn then e[#e + 1] = "alpn=" .. n.alpn end
|
||||
elseif proto == "socks5" or proto == "socks" then
|
||||
if n.alpn then
|
||||
-- alpn 可能是数组:sing-box JSON 与 Clash YAML 的 alpn 列表导入后就是 table,
|
||||
-- 直接拼接会 "attempt to concatenate a table value" 让整次导出失败
|
||||
local alpn = n.alpn
|
||||
if type(alpn) == "table" then alpn = table.concat(alpn, ",") end
|
||||
if alpn ~= "" then e[#e + 1] = "alpn=" .. alpn end
|
||||
end
|
||||
elseif proto == "socks5" or proto == "socks" or proto == "http" then
|
||||
-- socks5 / http 在 Surge 家族里都用 username= / password= 具名参数
|
||||
-- (Surge 手册:Name = http, <host>, <port>[, <username>, <password>]
|
||||
-- "may be given positionally after the port, or as named parameters")
|
||||
if n.username then
|
||||
e[#e + 1] = "username=" .. n.username
|
||||
if n.password then e[#e + 1] = "password=" .. n.password end
|
||||
@@ -70,7 +79,8 @@ function M.surge_line(n)
|
||||
|
||||
local line = (n.name or "") .. " = " .. head
|
||||
if #e > 0 then line = line .. ", " .. table.concat(e, ", ") end
|
||||
return line
|
||||
-- 节点名与各参数值都来自订阅(不可信):含换行会截断本行并伪造出一条新的代理行
|
||||
return util.one_line(line)
|
||||
end
|
||||
|
||||
-- 收集节点名列表
|
||||
@@ -112,7 +122,7 @@ local function surge_config(nodes, group_name, supports_ssr)
|
||||
local select = group_name .. " = select"
|
||||
for _, nm in ipairs(names) do select = select .. ", " .. nm end
|
||||
select = select .. ", DIRECT"
|
||||
out[#out + 1] = select
|
||||
out[#out + 1] = util.one_line(select)
|
||||
return table.concat(out, "\n")
|
||||
end
|
||||
|
||||
@@ -172,6 +182,7 @@ function M.to_qx(nodes, options)
|
||||
local proto = props(n)
|
||||
local host = (n.server or "") .. ":" .. tostring(n.port or 0)
|
||||
local tag = n.name or host
|
||||
local before = #out
|
||||
if proto == "shadowsocks" then
|
||||
out[#out + 1] = string.format(
|
||||
"shadowsocks=%s, method=%s, password=%s, tag=%s",
|
||||
@@ -199,13 +210,16 @@ function M.to_qx(nodes, options)
|
||||
host, n.password or "", tag)
|
||||
if n.sni then out[#out] = out[#out] .. ", tls-host=" .. n.sni end
|
||||
end
|
||||
-- 节点名 / sni / path / host 全部来自订阅(不可信),含换行会截断本行
|
||||
-- 并伪造出一条新的 server_local 行
|
||||
for i = before + 1, #out do out[i] = util.one_line(out[i]) end
|
||||
end
|
||||
out[#out + 1] = ""
|
||||
out[#out + 1] = "[policy]"
|
||||
local names = names_of(nodes)
|
||||
local policy = "static=" .. (options.name or "PROXY") .. ", DIRECT"
|
||||
for _, nm in ipairs(names) do policy = policy .. ", " .. nm end
|
||||
out[#out + 1] = policy
|
||||
out[#out + 1] = util.one_line(policy)
|
||||
out[#out + 1] = ""
|
||||
out[#out + 1] = "[server_remote]"
|
||||
return table.concat(out, "\n")
|
||||
|
||||
@@ -33,31 +33,32 @@ local function supported(proto)
|
||||
end
|
||||
|
||||
-- 构建 TLS 字段
|
||||
--
|
||||
-- enabled 必须显式写 true:sing-box 的 OutboundTLSOptions.Enabled 是
|
||||
-- `bool` + `json:"enabled,omitempty"`(option/tls.go),缺省值即 false。
|
||||
-- 上游 common/tls/client.go 里 `if !options.Enabled { return dialer, nil }`
|
||||
-- 会直接按明文拨号,hysteria2 / tuic 更会因为 `options.TLS == nil ||
|
||||
-- !options.TLS.Enabled` 返回 C.ErrTLSRequired 而拒绝启动。只输出
|
||||
-- server_name / insecure 而不带 enabled 等于没配 TLS。
|
||||
local function build_tls(n)
|
||||
local tls
|
||||
if n.security and n.security ~= "none" then
|
||||
tls = {}
|
||||
if n.sni or n.servername then tls.server_name = n.sni or n.servername end
|
||||
if n.alpn then
|
||||
if type(n.alpn) == "string" then
|
||||
local list = {}
|
||||
for p in n.alpn:gmatch("[^,]+") do list[#list + 1] = p:match("^%s*(.-)%s*$") end
|
||||
tls.alpn = list
|
||||
else
|
||||
tls.alpn = n.alpn
|
||||
end
|
||||
if not (n.security and n.security ~= "none") then return nil end
|
||||
local tls = { enabled = true }
|
||||
if n.sni or n.servername then tls.server_name = n.sni or n.servername end
|
||||
if n.alpn then
|
||||
if type(n.alpn) == "string" then
|
||||
local list = {}
|
||||
for p in n.alpn:gmatch("[^,]+") do list[#list + 1] = p:match("^%s*(.-)%s*$") end
|
||||
tls.alpn = list
|
||||
else
|
||||
tls.alpn = n.alpn
|
||||
end
|
||||
if n["skip-cert-verify"] ~= nil then
|
||||
tls.insecure = bool(n["skip-cert-verify"])
|
||||
elseif n.skip_cert_verify ~= nil then
|
||||
tls.insecure = bool(n.skip_cert_verify)
|
||||
end
|
||||
-- 空表会被 json_encode 编码成 [],而 tls 必须是对象;
|
||||
-- security=tls 但无 sni/alpn/insecure 时就会走到这里
|
||||
if next(tls) == nil then return util.JSON_EMPTY_OBJECT end
|
||||
return tls
|
||||
end
|
||||
return nil
|
||||
if n["skip-cert-verify"] ~= nil then
|
||||
tls.insecure = bool(n["skip-cert-verify"])
|
||||
elseif n.skip_cert_verify ~= nil then
|
||||
tls.insecure = bool(n.skip_cert_verify)
|
||||
end
|
||||
return tls
|
||||
end
|
||||
|
||||
-- 构建传输(transport)字段
|
||||
@@ -112,12 +113,23 @@ function M.to_outbound(n, tag)
|
||||
elseif stype == "shadowsocks" then
|
||||
o.method = n.method or n.cipher or "aes-256-gcm"
|
||||
o.password = n.password or ""
|
||||
elseif stype == "hysteria2" or stype == "hysteria" then
|
||||
elseif stype == "hysteria2" then
|
||||
o.password = n.password or ""
|
||||
-- 混淆(salamander)
|
||||
-- 混淆(salamander):hysteria2 的 obfs 是 { type, password } 对象
|
||||
if n.obfs and n.obfs ~= "" and n.obfs ~= "plain" then
|
||||
o.obfs = { type = n.obfs, password = n["obfs-password"] or n.obfs_password or "" }
|
||||
end
|
||||
elseif stype == "hysteria" then
|
||||
-- hysteria(v1) 与 hysteria2 在 sing-box 里的字段名并不相同(已对照上游文档确认):
|
||||
-- * 认证字段是 auth_str,不是 password(parser_json_config 也是按
|
||||
-- outbound.password or outbound.auth_str or outbound.auth 回读的)
|
||||
-- * obfs 是普通字符串,不是 { type, password } 对象
|
||||
-- 注意:sing-box 的 hysteria 出站还要求 up/down(带宽),本项目的节点模型
|
||||
-- 不承载该字段,需用户在自己的配置里补上(见 CHANGELOG 已知限制)。
|
||||
o.auth_str = n.password or ""
|
||||
if n.obfs and n.obfs ~= "" and n.obfs ~= "plain" then
|
||||
o.obfs = n.obfs
|
||||
end
|
||||
elseif stype == "tuic" then
|
||||
o.uuid = n.uuid or ""
|
||||
o.password = n.password or ""
|
||||
@@ -142,7 +154,8 @@ function M.to_outbound(n, tag)
|
||||
if n["listen-port"] then o.listen_port = tonumber(n["listen-port"]) end
|
||||
if n.mtu then o.mtu = tonumber(n.mtu) end
|
||||
if n.dns then o.dns = n.dns end
|
||||
elseif stype == "socks" then
|
||||
elseif stype == "socks" or stype == "http" then
|
||||
-- socks 与 http 出站的认证字段相同(username / password)
|
||||
if n.username then o.username = n.username end
|
||||
if n.password then o.password = n.password end
|
||||
end
|
||||
|
||||
@@ -84,11 +84,15 @@ function M.to_share_uri(n)
|
||||
local q = {}
|
||||
if n.sni then q[#q + 1] = "sni=" .. url_encode(n.sni) end
|
||||
if n.insecure ~= nil then q[#q + 1] = "insecure=" .. tostring(n.insecure) end
|
||||
-- 混淆(salamander):hy2 URI 标准参数 obfs / obfs-password
|
||||
-- 混淆:hysteria2 是 salamander,URI 参数为 obfs / obfs-password;
|
||||
-- hysteria(v1) 的 obfs 只是普通字符串,**没有** obfs-password
|
||||
-- (写到 v1 链接上是非法参数,且会误导下游客户端)
|
||||
if n.obfs and n.obfs ~= "" and n.obfs ~= "plain" then
|
||||
q[#q + 1] = "obfs=" .. url_encode(n.obfs)
|
||||
local opw = n["obfs-password"] or n.obfs_password
|
||||
if opw and opw ~= "" then q[#q + 1] = "obfs-password=" .. url_encode(opw) end
|
||||
if proto == "hysteria2" then
|
||||
local opw = n["obfs-password"] or n.obfs_password
|
||||
if opw and opw ~= "" then q[#q + 1] = "obfs-password=" .. url_encode(opw) end
|
||||
end
|
||||
end
|
||||
local suffix = #q > 0 and ("?" .. table.concat(q, "&")) or ""
|
||||
return (proto == "hysteria2" and "hysteria2://" or "hysteria://")
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
-- luci-app-substore
|
||||
-- 与 parser.parse_wireguard_conf 互为逆操作:导入解析 [Interface] / [Peer],此处按同格式写回。
|
||||
|
||||
local util = require("substore.util")
|
||||
|
||||
local M = {}
|
||||
|
||||
-- 仅 wireguard 节点可用 .conf 表达;其余协议无法落到单行/单段,直接丢弃
|
||||
@@ -156,7 +158,9 @@ function M.generate(nodes, options)
|
||||
return nil, "该 WireGuard 节点没有私钥 (private-key),无法导出 .conf"
|
||||
end
|
||||
|
||||
local lines = { "# " .. (n.name or ((n.server or "") .. ":" .. tostring(n.port or ""))) }
|
||||
-- 注释行也必须压成单行:节点名来自订阅(不可信),含换行时会注入出真正的
|
||||
-- 配置行(例如 "x\n[Interface]"),伪造出一段用户没写过的隧道配置
|
||||
local lines = { "# " .. util.one_line(n.name or ((n.server or "") .. ":" .. tostring(n.port or ""))) }
|
||||
for _, line in ipairs(build_section(n)) do lines[#lines + 1] = line end
|
||||
return table.concat(lines, "\n") .. "\n"
|
||||
end
|
||||
|
||||
@@ -10,9 +10,15 @@ local parser_surge = require("substore.parser_surge")
|
||||
|
||||
local M = {}
|
||||
|
||||
-- URI 文本导入支持的 scheme。
|
||||
-- 必须覆盖 output_uri.to_share_uri 能生成的全部 scheme,否则「导出→导入」回环会
|
||||
-- 丢节点:之前 hysteria / socks 只出不进(导出为 hysteria:// / socks5://,
|
||||
-- 再导入却报 "unsupported proto"),含这两类节点的订阅文本也会被整行丢弃。
|
||||
-- socks5 与 socks 都接受,统一归一为 proto="socks"(见 node.normalize)。
|
||||
local SUPPORTED = {
|
||||
vmess = true, vless = true, trojan = true, ss = true, ssr = true,
|
||||
hysteria2 = true, tuic = true, wireguard = true,
|
||||
hysteria = true, hysteria2 = true, tuic = true, wireguard = true,
|
||||
socks = true, socks5 = true,
|
||||
}
|
||||
|
||||
local function split_lines(content)
|
||||
@@ -363,6 +369,88 @@ local function parse_hysteria2(uri, body)
|
||||
return out
|
||||
end
|
||||
|
||||
-- hysteria://password@host:port/?sni=..&insecure=..&obfs=..#name(v1)
|
||||
-- 与 hysteria2 的区别(均已对照上游确认):
|
||||
-- * v1 的 obfs 是普通字符串,没有 obfs-password(obfs-password 是 hysteria2 的 salamander)
|
||||
-- * 两者在 mihomo / sing-box 里都是 TLS-only,因此同样补 security="tls"
|
||||
-- 认不出的查询参数一律忽略(不报错),保证第三方生成的链接仍能解析出 server/port/password。
|
||||
local function parse_hysteria(uri, body)
|
||||
local name, rest = "", body
|
||||
local hash = rest:find("#", 1, true)
|
||||
if hash then
|
||||
name = util.url_decode(rest:sub(hash + 1))
|
||||
rest = rest:sub(1, hash - 1)
|
||||
end
|
||||
local query = {}
|
||||
local qpos = rest:find("?", 1, true)
|
||||
local hp = rest
|
||||
if qpos then
|
||||
hp = rest:sub(1, qpos - 1)
|
||||
for k, v in rest:sub(qpos + 1):gmatch("([^&=]+)=([^&]*)") do
|
||||
query[k] = util.url_decode(v)
|
||||
end
|
||||
end
|
||||
local at = hp:find("@", 1, true)
|
||||
if not at then return nil, "bad hysteria" end
|
||||
local password = util.url_decode(hp:sub(1, at - 1))
|
||||
local host, port = util.split_hostport(hp:sub(at + 1))
|
||||
if not host or host == "" then return nil, "bad hysteria" end
|
||||
local out = node.normalize({
|
||||
proto = "hysteria", name = name, server = host, port = tonumber(port),
|
||||
password = password, raw = uri,
|
||||
})
|
||||
if query.sni then out.sni = query.sni end
|
||||
out.security = "tls"
|
||||
if query.insecure ~= nil then
|
||||
out.insecure = query.insecure
|
||||
out["skip-cert-verify"] = not (query.insecure == "0" or query.insecure == "false")
|
||||
end
|
||||
if query.obfs and query.obfs ~= "" then out.obfs = query.obfs end
|
||||
return out
|
||||
end
|
||||
|
||||
-- socks5://user:pass@host:port#name(socks:// 同义)
|
||||
-- 认证信息可选:没有 userinfo 时是无认证 socks。统一归一为 proto="socks"。
|
||||
local function parse_socks(uri, body)
|
||||
local name, rest = "", body
|
||||
local hash = rest:find("#", 1, true)
|
||||
if hash then
|
||||
name = util.url_decode(rest:sub(hash + 1))
|
||||
rest = rest:sub(1, hash - 1)
|
||||
end
|
||||
-- socks 没有标准查询参数,但第三方链接可能带 ? 尾巴,截掉以免污染 host:port
|
||||
local qpos = rest:find("?", 1, true)
|
||||
local hp = qpos and rest:sub(1, qpos - 1) or rest
|
||||
|
||||
local username, password
|
||||
-- 取**最后**一个 @ 作为 userinfo 与 authority 的分界:未编码的 @ 只可能出现在
|
||||
-- 密码里(userinfo 不允许裸 @),按第一个 @ 切会把 "p@ss" 这类密码切坏,
|
||||
-- 而且失败是静默的(解析出一个错的密码)。
|
||||
local userinfo, hostport = hp:match("^(.*)@([^@]*)$")
|
||||
if not userinfo then
|
||||
hostport = hp
|
||||
else
|
||||
userinfo = util.url_decode(userinfo)
|
||||
local cpos = userinfo:find(":", 1, true)
|
||||
if cpos then
|
||||
username = userinfo:sub(1, cpos - 1)
|
||||
password = userinfo:sub(cpos + 1)
|
||||
else
|
||||
username = userinfo
|
||||
end
|
||||
if username == "" then username = nil end
|
||||
end
|
||||
|
||||
local host, port = util.split_hostport(hostport)
|
||||
if not host or host == "" then return nil, "bad socks" end
|
||||
local out = node.normalize({
|
||||
proto = "socks", name = name, server = host, port = tonumber(port), raw = uri,
|
||||
})
|
||||
if username then out.username = username end
|
||||
if password then out.password = password end
|
||||
return out
|
||||
end
|
||||
|
||||
-- tuic://uuid:password@host:port/?congestion_control=..&alpn=..&sni=..#name
|
||||
local function parse_tuic(uri, body)
|
||||
local name, rest = "", body
|
||||
@@ -617,9 +705,11 @@ function M.parse_uri(uri)
|
||||
if proto == "vless" then return parse_vless(uri, body) end
|
||||
if proto == "trojan" then return parse_trojan(uri, body) end
|
||||
if proto == "vmess" then return parse_vmess(uri, body) end
|
||||
if proto == "hysteria" then return parse_hysteria(uri, body) end
|
||||
if proto == "hysteria2" then return parse_hysteria2(uri, body) end
|
||||
if proto == "tuic" then return parse_tuic(uri, body) end
|
||||
if proto == "wireguard" then return parse_wireguard(uri, body) end
|
||||
if proto == "socks" or proto == "socks5" then return parse_socks(uri, body) end
|
||||
return nil, "unsupported"
|
||||
end
|
||||
|
||||
@@ -730,38 +820,72 @@ local function parse_yaml_content(content)
|
||||
raw_nodes[#raw_nodes + 1] = current_node
|
||||
end
|
||||
|
||||
-- 协议映射:Clash type -> proto
|
||||
local proto_map = {
|
||||
vmess = "vmess",
|
||||
vless = "vless",
|
||||
trojan = "trojan",
|
||||
ss = "shadowsocks",
|
||||
ssr = "ssr",
|
||||
http = "http",
|
||||
socks5 = "socks5",
|
||||
}
|
||||
-- 协议映射:Clash type -> proto。
|
||||
-- 复用 parser_clash_yaml 的权威表(单一事实来源),本处曾自维护一份,缺
|
||||
-- hysteria2/hysteria/tuic/wireguard 且 socks5 未归一,后果是:
|
||||
-- * 认不出的 type 被 `or "vmess"` 兜底成 vmess —— 一份 sing-box YAML 里的
|
||||
-- hysteria2/tuic/wireguard 出站会变成 4 个没有 uuid 的假 vmess 节点(静默数据损坏)
|
||||
-- * socks5 原样保留,而节点页筛选/规则 proto_filter 用的是规范名 socks,节点查不到
|
||||
local proto_map = parser_clash_yaml.TYPE_MAP
|
||||
|
||||
-- 转换字段名并归一化
|
||||
local result = {}
|
||||
for _, n in ipairs(raw_nodes) do
|
||||
if not n or not n.server or not n.port then
|
||||
-- 跳过无效节点
|
||||
local proto = proto_map[n.type or n.proto]
|
||||
-- 上面的 in_proxies 分支同时接受 `proxies:`(Clash)与 `outbounds:`(sing-box)
|
||||
-- 两种段名,但两者字段名并不相同:Clash 用 port / name,sing-box 用
|
||||
-- server_port / tag。此前只读 port / name,导致 sing-box YAML 的 outbounds
|
||||
-- 全部卡在下面的 `not port` 判断上被静默丢弃(识别了段名却一个节点都拿不到)。
|
||||
-- 下列别名逐字对照 parser_json_config.parse_singbox_json 读取的键名,
|
||||
-- 那是本项目读取 sing-box 出站的权威实现,不另立一套。
|
||||
local port = n.port or n.server_port
|
||||
if not n or not n.server or not port or not proto then
|
||||
-- 跳过无效节点 / 未知协议。
|
||||
-- 未知协议不能兜底成 vmess:那是凭空造出一个字段全错的节点,
|
||||
-- 比丢弃更糟(用户看到"导入成功",实际拿到一批不可用的假节点)。
|
||||
else
|
||||
local proto = proto_map[n.type or n.proto] or "vmess"
|
||||
-- vmess 的加密方式:Clash 写 cipher,sing-box 写 security。
|
||||
-- security 在 node.normalize 里是 TLS 层,两者语义冲突,因此仅当取值
|
||||
-- 落在 vmess 加密方式白名单内时才当作 cipher 消费掉,否则留给 TLS 层。
|
||||
local vmess_cipher
|
||||
if proto == "vmess" then
|
||||
vmess_cipher = n.cipher
|
||||
if vmess_cipher == nil and n.security ~= nil
|
||||
and node.VMESS_CIPHERS[n.security] then
|
||||
vmess_cipher = n.security
|
||||
end
|
||||
end
|
||||
-- 同上的字符串真值问题:只认明确的真值写法
|
||||
local scv = n["skip-cert-verify"] or n.insecure
|
||||
local skip_cert_verify = nil
|
||||
if scv == true or scv == "true" or scv == "1" then skip_cert_verify = true
|
||||
elseif scv == false or scv == "false" or scv == "0" then skip_cert_verify = false end
|
||||
-- 注意不能用 `(cond) and nil or x` 写法:Lua 的 and/or 在 cond 为真时
|
||||
-- 结果是 nil,会被后面的 or 继续兜底,等于没生效
|
||||
local tls_security = n.security
|
||||
if vmess_cipher ~= nil then tls_security = nil end
|
||||
local node_data = {
|
||||
proto = proto,
|
||||
name = n.name or n.Name or (n.server .. ":" .. tostring(n.port or "")),
|
||||
name = n.name or n.Name or n.tag or (n.server .. ":" .. tostring(port)),
|
||||
server = n.server,
|
||||
port = tonumber(n.port),
|
||||
port = tonumber(port),
|
||||
uuid = n.uuid or n.id,
|
||||
password = n.password,
|
||||
-- sing-box 的 hysteria/hysteria2 认证字段是 password / auth_str / auth
|
||||
password = n.password or n.auth_str or n.auth,
|
||||
method = n.cipher or n.method,
|
||||
-- Clash 的 cipher 是 vmess 加密方式;tls 才是 TLS 层,
|
||||
-- 交由 node.normalize 归一到 security
|
||||
cipher = (proto == "vmess") and n.cipher or nil,
|
||||
cipher = vmess_cipher,
|
||||
tls = n.tls,
|
||||
security = n.security,
|
||||
sni = n.sni or n.servername,
|
||||
-- sing-box 的 vmess.security 已被上面消费为 cipher,不再当 TLS 层
|
||||
security = tls_security,
|
||||
-- Clash 写 sni / servername,sing-box 的 tls 子块写 server_name
|
||||
sni = n.sni or n.servername or n.server_name,
|
||||
-- sing-box 的 tls.insecure 对应 skip-cert-verify。
|
||||
-- 行解析器读出来的是字符串,而 "false" / "0" 在 Lua 里也是真值,
|
||||
-- 直接透传会让 skip-cert-verify=false 变成「跳过证书校验」,故显式判假。
|
||||
["skip-cert-verify"] = skip_cert_verify,
|
||||
net = n.net or n.network,
|
||||
alterId = tonumber(n.alterId),
|
||||
}
|
||||
result[#result + 1] = node.normalize(node_data)
|
||||
@@ -888,6 +1012,17 @@ function M.parse(content)
|
||||
-- 兼容标准 base64 与 base64url(- _ 无 padding):base64_url_decode 两者皆可
|
||||
local decoded = util.base64_url_decode(content)
|
||||
if decoded == "" then return nil, "Base64 解码失败" end
|
||||
-- 解出来的内容本身可能是 YAML / JSON:机场把整份 Clash 配置或 sing-box
|
||||
-- 配置 base64 后直接下发是很常见的做法。原先一律按 URI 列表解析,这类
|
||||
-- 订阅会得到 0 个节点且不报错(用户只看到「订阅为空」)。
|
||||
-- 递归走一遍 detect/parse 复用既有分支;inner == "base64" 时不再递归,
|
||||
-- 避免 base64 套 base64 时无限递归。外层容器格式仍是 base64。
|
||||
local inner = M.detect(decoded)
|
||||
if inner ~= "base64" and inner ~= "empty" and inner ~= "unknown" then
|
||||
local res, err = M.parse(decoded)
|
||||
if res then res.format = "base64" end
|
||||
return res, err
|
||||
end
|
||||
return { nodes = parse_lines(split_lines(decoded)), format = "base64" }
|
||||
elseif format == "json" then
|
||||
local nodes, err = parse_json_content(content)
|
||||
|
||||
@@ -7,7 +7,10 @@ local node = require("substore.node")
|
||||
|
||||
local M = {}
|
||||
|
||||
-- Clash type → 统一 proto
|
||||
-- Clash type → 统一 proto。
|
||||
-- 这是**权威映射表**:parser_json_config 与 parser.lua 的简易 YAML 兜底解析都复用同一份,
|
||||
-- 避免各自维护一份而漂移(曾出现兜底那份缺 hysteria2/hysteria/tuic/wireguard,
|
||||
-- 且把 socks5 原样留下,导致这些节点被误当成 vmess 或被筛选器静默丢弃)。
|
||||
local TYPE_MAP = {
|
||||
vmess = "vmess",
|
||||
vless = "vless",
|
||||
@@ -24,6 +27,9 @@ local TYPE_MAP = {
|
||||
http = "http",
|
||||
}
|
||||
|
||||
-- 导出供 parser.lua 的简易 YAML 兜底解析复用(单一事实来源)
|
||||
M.TYPE_MAP = TYPE_MAP
|
||||
|
||||
-- 标量值转换:布尔 / 数字 / 去引号 / 保留字符串
|
||||
local function scalar(raw)
|
||||
raw = raw or ""
|
||||
@@ -47,6 +53,74 @@ local function scalar(raw)
|
||||
return raw
|
||||
end
|
||||
|
||||
-- 去掉行尾注释。YAML 规定内联注释的 # 前必须有空白,引号内的 # 不算注释。
|
||||
-- 不处理的话 `proxies: # 说明` 会把注释文本当成值,proxies 变成字符串,
|
||||
-- 整份配置被判定为「没有 proxies」→ 0 个节点且不报错。
|
||||
local function strip_comment(v)
|
||||
local in_s, in_d = false, false
|
||||
for i = 1, #v do
|
||||
local c = v:sub(i, i)
|
||||
if in_s then
|
||||
if c == "'" then in_s = false end
|
||||
elseif in_d then
|
||||
if c == '"' then in_d = false end
|
||||
elseif c == "'" then in_s = true
|
||||
elseif c == '"' then in_d = true
|
||||
elseif c == "#" and (i == 1 or v:sub(i - 1, i - 1):match("%s")) then
|
||||
return (v:sub(1, i - 1):gsub("%s*$", ""))
|
||||
end
|
||||
end
|
||||
return v
|
||||
end
|
||||
|
||||
-- 按顶层分隔符切分,忽略引号内以及 {} / [] 内部的同级分隔符
|
||||
local function split_top(s, sep)
|
||||
local parts, buf = {}, {}
|
||||
local depth, in_s, in_d = 0, false, false
|
||||
for i = 1, #s do
|
||||
local c = s:sub(i, i)
|
||||
if in_s then
|
||||
if c == "'" then in_s = false end
|
||||
buf[#buf + 1] = c
|
||||
elseif in_d then
|
||||
if c == '"' then in_d = false end
|
||||
buf[#buf + 1] = c
|
||||
elseif c == "'" then
|
||||
in_s = true; buf[#buf + 1] = c
|
||||
elseif c == '"' then
|
||||
in_d = true; buf[#buf + 1] = c
|
||||
elseif c == "{" or c == "[" then
|
||||
depth = depth + 1; buf[#buf + 1] = c
|
||||
elseif c == "}" or c == "]" then
|
||||
depth = depth - 1; buf[#buf + 1] = c
|
||||
elseif c == sep and depth == 0 then
|
||||
parts[#parts + 1] = table.concat(buf); buf = {}
|
||||
else
|
||||
buf[#buf + 1] = c
|
||||
end
|
||||
end
|
||||
parts[#parts + 1] = table.concat(buf)
|
||||
return parts
|
||||
end
|
||||
|
||||
-- 解析 YAML 流式映射 {...}。Clash 机场配置常用
|
||||
-- `- {name: A, type: vmess, server: 1.1.1.1, port: 443}` 这种写法:它是合法 YAML,
|
||||
-- 但不是合法 JSON(键没有加引号),util.json_decode 必然失败,节点被整条静默
|
||||
-- 丢弃(0 个节点且无报错)。
|
||||
local function parse_flow_map(s)
|
||||
local body = s:match("^%s*{(.*)}%s*$")
|
||||
if not body then return nil end
|
||||
local map = {}
|
||||
for _, part in ipairs(split_top(body, ",")) do
|
||||
local k, v = part:match("^%s*([^:]+):%s*(.*)$")
|
||||
if k then
|
||||
map[k:gsub("%s*$", "")] = scalar(strip_comment(v))
|
||||
end
|
||||
end
|
||||
if next(map) == nil then return nil end
|
||||
return map
|
||||
end
|
||||
|
||||
-- 预处理内容为 (indent, rest) 列表,跳过空行、注释、文档分隔符
|
||||
local function preprocess(content)
|
||||
local items = {}
|
||||
@@ -79,6 +153,9 @@ local function parse_yaml(content)
|
||||
pos = pos + 1
|
||||
else
|
||||
k = k:gsub("%s*$", "")
|
||||
-- 行尾注释必须先去掉,否则 `proxies: # 说明` 会把注释文本当成值,
|
||||
-- proxies 变成字符串,整份配置被判定为「没有 proxies」
|
||||
v = strip_comment(v)
|
||||
pos = pos + 1
|
||||
if v == "" or v == "|" or v == ">" then
|
||||
if pos <= n_items and items[pos].ind > it.ind then
|
||||
@@ -108,12 +185,17 @@ local function parse_yaml(content)
|
||||
if it.rest == "-" then dash = "" else dash = it.rest:match("^-%s*(.*)$") end
|
||||
if dash == nil then break end
|
||||
pos = pos + 1
|
||||
-- 列表项也要去掉行尾注释:`- {a: 1} # 说明`、`- "::/0" # 说明`
|
||||
-- 都会让注释文本混进值里(流式映射那条甚至会整项解析失败)
|
||||
dash = strip_comment(dash)
|
||||
|
||||
local m = nil
|
||||
-- 流式 JSON 对象:- {"name":"...","type":"vmess",...}(机场 clash 配置常见写法)
|
||||
if dash:sub(1, 1) == "{" then
|
||||
-- 先按 JSON 解(键带引号的流式写法),失败再按 YAML 流式映射解
|
||||
local obj = util.json_decode(dash)
|
||||
if type(obj) == "table" then m = obj end
|
||||
if not m then m = parse_flow_map(dash) end
|
||||
else
|
||||
local k, v = dash:match("^([^:]+):%s*(.*)$")
|
||||
-- 引号开头的列表项是标量而非映射:避免把 "- \"::/0\"" 里的冒号
|
||||
@@ -152,7 +234,15 @@ local function map_clash_node(p)
|
||||
if type(p) ~= "table" then return nil end
|
||||
if not p.name or not p.server or not p.port then return nil end
|
||||
|
||||
local proto = TYPE_MAP[p.type] or p.type or "vmess"
|
||||
-- 未知协议必须丢弃,不能原样透传、也不能兜底成 vmess:
|
||||
-- * 原样透传:统一模型承载不了 snell / shadowtls / mieru / ssh 等类型(认证
|
||||
-- 方式与字段都不同),透传出去会在输出端变成 sing-box 的 type: "snell"、
|
||||
-- Xray 的 protocol: "snell" 这类非法取值,客户端会拒绝加载整份配置。
|
||||
-- * 兜底成 vmess:那是凭空造出一个字段全错的节点,比丢弃更糟(用户看到
|
||||
-- "导入成功",实际拿到一批不可用的假节点)。
|
||||
-- parser.lua 的简易 YAML 兜底解析(复用同一张 TYPE_MAP)早已按此处理,此处对齐。
|
||||
local proto = TYPE_MAP[p.type]
|
||||
if not proto then return nil end
|
||||
local n = {
|
||||
proto = proto,
|
||||
name = p.name,
|
||||
|
||||
@@ -102,7 +102,10 @@ function M.parse_singbox_json(content)
|
||||
node_data.password = outbound.password
|
||||
end
|
||||
|
||||
-- sing-box 用 tls 对象表达 TLS 层(enabled 缺省即 true)
|
||||
-- sing-box 用 tls 对象表达 TLS 层。注意 enabled 的缺省值是 false
|
||||
-- (option/tls.go: `Enabled bool` + omitempty),本输出模块因此总是
|
||||
-- 显式写 enabled=true。读取侧这里放宽为「只要不是显式 false 就当作
|
||||
-- 启用」,以便导入那些省略 enabled 的第三方配置时仍能保留 TLS 层。
|
||||
if outbound.tls and type(outbound.tls) == "table" then
|
||||
if outbound.tls.server_name then
|
||||
node_data.sni = outbound.tls.server_name
|
||||
|
||||
@@ -77,12 +77,46 @@ function M.shq(s)
|
||||
return "'" .. tostring(s or ""):gsub("'", "'\\''") .. "'"
|
||||
end
|
||||
|
||||
-- 把值压成单行。Surge / Loon / Quantumult X / wg-quick .conf 都是行式格式:
|
||||
-- 值里一旦含换行,就会截断当前行并伪造出额外的一行(节点名、sni、path 等
|
||||
-- 全部来自订阅内容,属于不可信输入)。换行/回车压成空格,其余控制字符丢弃。
|
||||
function M.one_line(s)
|
||||
if s == nil then return nil end
|
||||
return (tostring(s):gsub("[\r\n]+", " "):gsub("%c", ""))
|
||||
end
|
||||
|
||||
-- 生成随机十六进制 token(用于下载链接访问控制)
|
||||
--
|
||||
-- 不能直接用 math.random 产出:Lua 5.1 的 math.random 是 31 位 LCG,而原实现
|
||||
-- 每次调用都重新 randomseed,同一时钟刻度内的多次调用会给出完全相同的序列
|
||||
-- (实测 20 万次调用只有约 18 万个不同值,约 9.7% 的 token 重复)。订阅下载
|
||||
-- token 是访问控制的唯一凭据,重复即可被猜测。优先直接读内核熵源,
|
||||
-- 只有在 /dev/urandom 不可用时才退回 PRNG(且只播种一次)。
|
||||
local prng_seeded = false
|
||||
local function seed_prng()
|
||||
if prng_seeded then return end
|
||||
prng_seeded = true
|
||||
math.randomseed(os.time() + math.floor((os.clock() * 1000000) % 1000000))
|
||||
end
|
||||
|
||||
function M.rnd_hex(len)
|
||||
len = len or 16
|
||||
local f = io.open("/dev/urandom", "rb")
|
||||
if f then
|
||||
local need = math.ceil(len / 2)
|
||||
local bytes = f:read(need)
|
||||
f:close()
|
||||
if bytes and #bytes == need then
|
||||
local out = {}
|
||||
for i = 1, #bytes do
|
||||
out[#out + 1] = string.format("%02x", bytes:byte(i))
|
||||
end
|
||||
return (table.concat(out):sub(1, len))
|
||||
end
|
||||
end
|
||||
seed_prng()
|
||||
local hex = "0123456789abcdef"
|
||||
local out = {}
|
||||
math.randomseed(os.time() + (os.clock() * 1000000 % 1000000) + math.random(0, 65535))
|
||||
for _ = 1, len do
|
||||
local idx = math.random(1, 16)
|
||||
out[#out + 1] = hex:sub(idx, idx)
|
||||
@@ -345,17 +379,33 @@ function M.file_size(path)
|
||||
return #data
|
||||
end
|
||||
|
||||
-- 原子写:先写临时文件再重命名,避免写一半损坏
|
||||
-- 原子写:先写临时文件再重命名,避免写一半损坏。
|
||||
--
|
||||
-- 两处必须做对,否则「原子」只是名义上的:
|
||||
-- 1) 临时文件名不能固定成 "<path>.tmp":两个进程同时写同一路径时会交错写进
|
||||
-- 同一个临时文件,rename 上去的是两者内容的混合体,各自的原子性都失效。
|
||||
-- 2) write / close 的返回值必须检查:磁盘写满或写入被截断时 f:write 会失败,
|
||||
-- 但 os.rename 依然成功 —— 于是原子地换上一个残缺文件,调用方却以为成功,
|
||||
-- 下一次读取才发现数据没了。
|
||||
function M.atomic_write(path, content)
|
||||
local tmp = path .. ".tmp"
|
||||
local tmp = string.format("%s.tmp.%s", path, M.rnd_hex(8))
|
||||
local f, e = io.open(tmp, "wb")
|
||||
if not f then return false, e end
|
||||
f:write(content)
|
||||
f:close()
|
||||
local ok, e2 = os.rename(tmp, path)
|
||||
if not ok then
|
||||
local wok, werr = f:write(content)
|
||||
if not wok then
|
||||
f:close()
|
||||
os.remove(tmp)
|
||||
return false, e2
|
||||
return false, werr
|
||||
end
|
||||
local cok, cerr = f:close()
|
||||
if not cok then
|
||||
os.remove(tmp)
|
||||
return false, cerr
|
||||
end
|
||||
local rok, rerr = os.rename(tmp, path)
|
||||
if not rok then
|
||||
os.remove(tmp)
|
||||
return false, rerr
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
Reference in new issue
Block a user