99 KiB
遗留缺陷与已知限制汇总
决策状态:本文件所列各项均已决策并处置完毕([2.6.16-r1] 为最后一批,
见下文「六」)。仍留在这里是因为它们记录了「为什么是这个处置」—— 其中一部分是
有意保留的已知限制,不是待办;另有几项写明「待上游规范可核实后再定」。
本文件只记录不在 P2 范围内、需要另行决策的项。
当前进度:
| 批次 | 范围 | 状态 |
|---|---|---|
| P0 | 14 项高危(输出整份不可用 / 数据永久丢失 / 安全) | ✅ 已完成(4 次推送,见 CHANGELOG [2.6.0-r2]~[2.6.0-r5]) |
| P1 | 13 项中危 + 审计中实测确认的 4 项新缺陷 | ✅ 已完成([2.6.1-r1]) |
| P2 | 其余 M / L 项 | ✅ 已完成(批次一 [2.6.3-r1]、批次二 [2.6.4-r1]、批次三 [2.6.5-r1]、批次四 [2.6.6-r1]、批次五 [2.6.7-r1]、批次六 [2.6.8-r1]、批次七 [2.6.9-r1]「P2 最后一批」)。范围定义里有 M6 / L15 / L19 三项无据可查,见文末「附」 |
| P3 | 输出层代码级复审(10 项)+ shadowsocks SIP003 插件全链路(1 项) | ✅ 已完成([2.6.10-r1],见下文「四」) |
| P4 | 遗留项决策后实施(1.1 / 1.3 / 1.4 / 1.5 / 2.5 五项,3.3 补文档) | ✅ 已完成([2.6.11-r1],见下文「五」) |
| P5 | 决策项实施(wget 路径 SSRF、删除部分失败漏写 cron、混合格式静默丢弃、写入失败被忽略、M28 token、M29 ACL;wget 体积/重定向记文档) | ✅ 已完成([2.6.16-r1],见下文「六」) |
编写纪律:只记录经代码级审计或实测探针确认的结论,不含推测。原始 70 项 审计表(高危 14 / 中危 30 / 低危 26)未落盘,本文件自本次起作为「未修复项」的 权威清单。已修复项见 CHANGELOG.md。
一、本轮(P1 之后)确认的遗留项
1.1 H10 的「无锁」部分 —— load() → save() 丢更新(已修复 [2.6.11-r1])
已修的部分:H10 的另一半(临时文件名固定为 path..".tmp" 被并发写者共用)
已在 P0 批次三修复,改为 path..".tmp.<8 字节随机 hex>"。
未修的部分:读-改-写全程仍无互斥。
代码级依据:
core.lua的local function load()读出整表,local function save(seq, items)整表写回,中间无锁;M.add/M.add_local/M.add_combo/M.save_meta/M.save_combo均走这条路径。util.lua中不存在任何锁原语(无flock、无mkdir锁、无link())。nixio只用于http.lua:208-210的getaddrinfo(util.try_require可选加载),Makefile的LUCI_DEPENDS未声明+nixio。
并发来源:LuCI 页面保存订阅、cron 定时更新、组合订阅自动重算,三者可能同时发生。
影响:后写者整表覆盖先写者 → 订阅丢失或新增丢失。与已修复的 H8 同属「数据被 抹除」,但触发条件是并发而非文件损坏,H8 的修复不覆盖本项。
候选方案:
| 方案 | 做法 | 代价 / 风险 |
|---|---|---|
| A. 记为已知限制 | 文档说明「同一时间只应有一个写操作」 | 零成本;用户无从感知,仍会丢数据 |
B. 引入 nixio flock |
Makefile 加 +nixio,写路径加文件锁 |
最可靠;新增运行时依赖,本机无法验证 |
C. mkdir 锁 + 陈旧锁回收 |
目录创建做互斥,带超时与持有者 PID 回收 | 纯 Lua 无依赖;需正确处理崩溃残留锁,否则死锁 |
| D. 写路径单点串行化 | 所有写入收敛到同一入口 | 跨进程串行仍需 OS 原语,实际仍要 B 或 C |
1.2 M28 / M29 —— CSRF post_ok() 与 ACL
状态:已修复([2.6.16-r1])。原「跳过验证」的处置已作废 —— 两项依赖的
LuCI 框架行为本轮已从上游源码逐条核实(依据见下),不再是猜测,故按核实结果实施。
M28(post_ok() 空 token 放行)—— 已修复
原缺陷:substore.lua 的 post_ok() 只判 formvalue("token") ~= nil,空串也通过;
且所有 entry 均未声明 post,框架的 test_post_security 从未执行。
框架行为核实(上游 luci/luci-base,非推测):
luci/dispatcher.lua的test_post_security()只在entry.post为真时才被调用; 本控制器未声明post,因此该函数确实从未执行 —— 原判断成立。luci/template.lua中 viewns 元表的token键返回disp.context.authtoken, 即模板里的token就是luci.dispatcher.context.authtoken。因此「把提交的 token 与context.authtoken比对」正是框架自身的判据,不会误拒任何合法表单。
修复内容:post_ok() 现在要求 token 存在且非空,并在能取到 authtoken 时
要求两者相等;取不到 authtoken(非标准运行环境)时退回「非空即通过」,
不因取不到值而拒绝全部请求。失败时通过 post_fail_msg 把原因回显到列表页,
不再静默失败。同时补齐了原先 9 处忽略返回值的调用点(7 处 back_to_list()、
2 处 back_to_nodes()),使校验失败必然被用户看到。
回归测试:tests/controller_robustness_test.lua 的 M28 段(6 条断言),
覆盖「合法 token 通过 / 空 token 拒绝 / 不匹配拒绝 / create / update / node_save
四类写操作均受检 / 取不到 authtoken 时的回退」。反向验证:修复前 7 条断言失败。
M29(无 ACL)—— 已修复
原缺陷:menu.d 无 depends.acl、仓库内无 rpcd ACL 文件 → 任意已登录 LuCI 用户
可读写全部订阅(含凭据 URL)。
ACL 结构核实(上游 luci-base 与 luci-app-commands,非推测):acl.d 的顶层是
{ "<组名>": { "description": …, "read": { "uci": [...] }, "write": { "uci": [...] } } };
menu.d 用 "depends": { "acl": [ "<组名>" ] } 引用。
修复内容:新增 root/usr/share/rpcd/acl.d/luci-app-substore.json 定义
luci-app-substore 组(读写 uci: substore),menu.d 两个条目均声明
depends.acl 引用它,Makefile 增加对应的安装规则。
执行范围(已从上游源码核实,非推测):
menu.d 的 depends.acl 是入口级门禁,不只是「菜单里看不见」。核实过程:
- ucode dispatcher(23.05+,现代目标机上实际运行的那套):
modules/luci-base/ucode/dispatcher.uc的build_pagetree()把/usr/share/luci/menu.d/*.json与 Lua 控制器 (/usr/lib/lua/luci/controller/*/*.lua)glob 进同一棵树;dispatch()逐段走这棵树并调用ctx_append(ctx, name, node),而ctx_append里是push(ctx.acls, ...(node?.depends?.acl || []))—— 路径上每个节点的depends.acl都会被累积。随后:即:缺少该组的用户直接访问 URL 会拿到 403。if (length(resolved.ctx.acls)) { let perm = check_acl_depends(resolved.ctx.acls, resolved.ctx.authacl?.['access-group']); if (perm == null) { http.status(403, 'Forbidden'); return; } } - ACL 沿路径累积这一点还带来一个有用的推论:把 ACL 挂在父节点
admin/services/substore上,就已覆盖它的全部子路由 (form/nodes/delete/save/node_save/update/probe…)。 本应用的所有动作都在该节点之下,因此这 18 个entry一并受保护。 - 旧版 Lua dispatcher(≤ 22.03)行为不同,但不在支持范围内:
luasrc/dispatcher.lua里menu_json()把控制器树与 menu.d 树 merge 后apply_tree_acls,那只影响 菜单渲染;dispatch()用的是控制器树,其depends.acl只来自entry.acl_depends。也就是说在 22.03 上仅靠 menu.d 只有「菜单看不见」。 本项目最低支持 23.05(用户 2026-10-02 确认),23.05 起用的是 ucode dispatcher, 即上面那套入口级 403 语义。因此无需为旧版补entry.acl_depends—— 记录在此 只为说明「为什么 22.03 不在支持范围内时这个缺口可以不管」,不是待办。
设备实测(已完成并通过,2026-10-02):以上结论最初来自读上游源码(本机无
LuCI 运行环境),随后按 docs/TESTING.md 第 9 项在目标设备复核,三项断言全部通过:
- 非 root 且不在
luci-app-substore组内的 LuCI 用户:看不到本应用菜单入口 - 把该用户加入组后:入口出现
- 该用户直接访问
/cgi-bin/luci/admin/services/substore/list:返回 403 Forbidden
即:入口级 403 的语义在真实设备上成立,与上文读源码得出的推断一致。
回滚方式:删除 root/usr/share/rpcd/acl.d/luci-app-substore.json(及 Makefile 中
对应的两行安装规则),并移除 menu.d 两个条目里的 depends 块。
回归测试:tests/acl_menu_test.lua(19 条断言)固化跨文件接线 —— ACL 组定义、
菜单引用同一个组名、菜单路径在控制器中确实注册、Makefile 确实安装这两个文件。
这条链路上任何一环写错都不会报错,只会静默失效(ACL 形同虚设),故必须由测试
锁住。反向验证:修复前 13 条断言失败。
1.3 sing-box 读取侧:transport 整层丢失(已修复 [2.6.11-r1])
现象:sing-box 配置中 ws / grpc / h2 的传输参数在导入后完全丢失,节点退化 为 tcp 直连。
实测探针(parser.parse,vmess + transport: {type: ws, path: /ws, headers.Host}):
JSON -> proto=vmess net=tcp sni=a.com fp=nil path=nil host=nil
YAML -> proto=vmess net=tcp sni=a.com fp=chrome path=nil host=nil
代码级依据:
parser_json_config.lua(parse_singbox_json,314 行)全文不含transport/path/host/utls任何一处。它读的是outbound.network, 而 sing-box 表达传输方式用的是transport.type—— 该字段在 sing-box 出站里 并不存在,因此net恒为默认值tcp。- 简易 YAML 解析器(
parser.lua)只展开tls:子块,同样不处理transport。
影响:ws / grpc / h2 节点在机场导出的 sing-box 配置里占相当比例,导入后全部按 tcp 直连,握手必然失败,且不报错。比原记录(「不读 path/host」)更严重:连传输 类型本身都没读进来。
附带不一致:tls.utls.fingerprint 在 YAML 侧已读(fp=chrome,P1 修复),
JSON 侧不读(fp=nil)。
候选方案:在 parse_singbox_json 中补 transport.type → net、
transport.path → path、transport.headers.Host → host、
tls.utls.fingerprint → fp;并让简易 YAML 侧同样处理 transport。
权威字段名已核实([2.6.10-r1],取自上游
sing-box.sagernet.org/configuration/shared/v2ray-transport/,可直接照此实现,
无需再猜):
transport.type |
本项目的 net |
字段映射 |
|---|---|---|
ws |
ws |
path → path;headers.Host → host |
grpc |
grpc |
service_name → path |
http |
http |
path → path;host 是数组,取首个 → host |
httpupgrade |
http |
path → path;host 是单个字符串 → host |
quic |
无对应 | 本项目模型没有 quic,按未知传输处理(回落 tcp 或丢弃) |
另:tls.utls.fingerprint → fp(YAML 侧已读,JSON 侧未读,需对齐);
tls.enabled 为 false 时不得置 security。
修复建议:本轮修复([2.6.10-r1] 未实施,留待下一轮)。它与 F5 / F6 / F11
同属「静默丢传输层 → 节点连不上且不报错」,而 ws / grpc 节点在机场的 sing-box
配置里占比很高,影响面比 F5/F6 更大。实施时两侧(JSON 与简易 YAML)必须一起改,
否则同一条订阅走两条导入路径会得到不同的节点。
1.4 converter.lua / node_converter.lua 为死代码(已删除 [2.6.11-r1])
(即审计表 L2)
代码级依据:
root/下没有任何文件require这两个模块 —— 唯一的引用是converter.lua:7引用node_converter.lua,以及tests/下的单元测试。- LuCI controller 与前端 JS 中不存在「转换」相关的路由或 UI。
- 模块实现本身也有问题:禁止 shadowsocks 转换;
hysteria2/tuic/wireguard → "sing-box"/"clash"产出非法 proto;node_converter.convert因network/fingerprint字段名不匹配丢net/path/host/fp;core.generate_link不传 opts →options.proto永不被设置。
README 声明:README.md:59「协议转换:任意协议 → 任意协议」、
README.en.md:68「Protocol conversion: any node type → any other type」;
紧接其后的 README.md:60-62 又说明「SSR 不能与 vmess/vless 等其它协议互转」,
与上一行自相矛盾。
候选方案:A. 接入 UI(需先定义「哪些协议可互转」的权威矩阵并审计现有映射表); B. 删除两个模块与测试,修正 README 两处声明。
1.5 Quantumult X:节点名含逗号时 tag= 字段本身被写坏(已按方案 B 修复 [2.6.11-r1])
发现于:P2 批次三修复 L21 时顺带发现。L21 只处理了成员列表(Surge 家族
[Proxy Group] 与 QX [policy]),已修复;本条是同一根因在 QX 定义行上的
另一处表现。
实测(已验证的部分):output_formats.lua 的 to_qx 用
string.format("trojan=%s, password=%s, over-tls=true, tag=%s", …) 拼行,
tag 直接取自 n.name。节点名 A,B 经 util.one_line 后仍含逗号,
输出为:
trojan=1.2.3.4:443, password=p, over-tls=true, tag=A,B
[server_local] 行内是逗号分隔的 key=value 字段序列,名字里的逗号落在
tag= 的值里,按该语法会被读成字段分隔符。
补充([2.6.10-r1]):同一条行语法在参数值上的同类问题已修复 —— to_qx
现在先把具名字段攒成列表、逐字段判定值里是否含逗号,含逗号的整条丢弃(连同
[policy] 成员),不再出现 password=pa,ss 被静默截断成 password=pa。
本条讨论的 tag= 名字部分已于 [2.6.11-r1] 按下面的方案 B 一并处理:
to_qx 现在对「one_line 之后仍含逗号」的节点整条丢弃(定义行与 [policy] 成员
一起),判定条件与 names_of 完全一致,两边不会再对不上。
未验证的部分(不猜):QX 对这种「最后一个字段值里多出逗号」的行究竟是 报错、忽略多余片段,还是原样接受,本机没有 Quantumult X 可供实测。 因此本条按「待核实」记录,未作修改。
候选方案(三选一,需先核实 QX 实际行为再定):
- A. 改名:输出时把名字里的
,替换为安全字符。节点不丢失,但导出名与 LuCI 中显示的名字不一致;且A,B与A_B两个节点会撞名(Surge / QX 对 重名代理的处理需另行核实)。 - B. 丢弃:把含逗号名字的节点从 QX 输出中整体剔除(连定义行一起)。 与「Surge 家族丢弃 wireguard / ssr、Clash 原版丢弃不支持的协议」的既有约定 一致,产出必定合法;代价是节点静默消失。
- C. 维持现状:仅在文档中说明「QX 输出请勿使用含逗号的节点名」。
注:Surge 家族的 [Proxy] 定义行是 NAME = type, host, port, …,
名字在 = 左侧,逗号大概率不影响解析(未实测),故本条只针对 QX。
推荐方案:B(丢弃)。理由:
- A(改名)会引入新的重名风险,而「重名代理」在 Surge / QX 上的行为同样未核实 —— 等于用一个未核实的问题换掉另一个,不划算。
- C(维持现状)会让用户拿到一份静默损坏的配置,与本项目「宁可丢节点也不输出 损坏行」的既有约定(Surge 家族丢 wireguard / ssr、Clash 原版丢不支持的协议)相悖。
- B 与 F7 已落地的处置完全一致,实现上只是把
names_of的排除条件也用到定义行上, 改动小、行为可预期。
实施前仍建议先核实 QX 对多余逗号片段是报错还是忽略 —— 若确认是「忽略多余片段、
tag 取第一段」,则 C 也可接受,届时再定。
二、P0 时代的遗留项(2.6.0 审计时记录)
来源:CHANGELOG [2.6.0-r1] 的「已知限制(本轮不修,均有明确原因)」。以下逐条
复核过当前代码,均仍然成立。
2.1 M11 混合格式文本导入不支持(已按方案 B+ 修复 [2.6.16-r1])
依据:parse_local → parse → detect 只识别一种格式,其余部分被静默丢弃
(实测:「URI + WG conf」只剩 WG 节点;「URI + JSON」只剩 URI 节点)。
性质:功能缺失而非小缺陷,需要重新设计 parser 的分段架构(规格 §22 明确 警告不要直接采用未经验证的逐行算法)。
候选方案:A. 设计分段架构后实现(工作量大,属 minor 版本特性); B. 在 README / UI 明确「一次只支持一种格式」(README 已如实说明,但 UI 未提示)。
已实施方案 B+(拒绝而非静默丢弃):新增 M.detect_all(content) 收集文本中
全部出现的格式,本地文本导入(parse_local 文本模式)在多于一种时明确报错
并列出两种格式名,而不是交给 detect() 挑一种、悄悄丢掉其余。这比 B 更进一步:
B 只是提示,用户仍会拿到一份少了一半节点的配置;现在是导入直接失败,不存在
「以为成功」的中间态。
实施中修正的两处误判(若不修,会把合法配置拒之门外,属新 bug):
- Surge / Clash 的 INI 段头
[Proxy]等会被朴素的^%s*%[规则误判为 JSON 数组, 于是一份合法 Surge 配置被判成surge + json而拒绝。已加is_ini_section_head()排除段头(段头内是标识符,不是引号字符串)。 - 「URI + JSON」这个方向起初漏检(原判据只看首个非空字符)。已补
has_json_object_line()(^%s*{%s*$或^%s*{%s*")做行级判定,两个方向都能识别。
刻意限定范围(如实记录):守卫只作用于本地文本导入。远程订阅走
M.detect / M.parse,未改动 —— 改动它会让既有远程订阅的解析行为变化,
风险大于收益。因此远程订阅内容若混用格式,仍然是静默丢弃,与修复前一致。
回归测试:tests/parser_mixed_format_test.lua(70 条断言)—— A 组确认单一格式
(含带 proxy-provider 的 Clash YAML、带 url 规则的 sing-box JSON、YAML 流式映射、
[Interface] / [Proxy])不会被误判;B 组确认 7 种混用组合都明确报错且错误信息
含两种格式名;C 组确认远程路径 M.parse 行为未变。反向验证:修复前探针显示
「URI + WG conf」得到 nodes=1, err=nil(静默丢弃)。
2.2 sing-box 的 hysteria(v1) 出站还要求 up / down(带宽)
状态:确认为已知限制([2.6.16-r1] 决策,方案 A) —— 不改代码,写入用户文档
(README「已知限制」)。
依据:output_singbox.lua:127 有明确注释说明;本项目的节点模型不承载该字段,
凭空填默认值属于猜测,故不输出。
影响:导出到 sing-box 的 hysteria v1 节点需用户自行补 up/down。
候选方案:A. 保持现状(文档说明);B. 在节点模型中增加 up/down 字段并贯通
解析 / 表单 / 输出(属新功能)。
决策理由:选 A。B 需要先定字段名与单位(bps 还是 Mbps、是否成对、缺省值取多少), 这些都是未经核实的规格问题,按「不猜」纪律不能凭推测落码;且它属于新功能, 按版本约定应进 minor 版本,不适合混在本轮的缺陷修复里。
2.3 hysteria(v1) 的上游 URI 规范未能核实
状态:确认为已知限制([2.6.16-r1] 决策,方案 A) —— 不改代码,写入用户文档
(README「已知限制」)。
依据:上游文档站点持续 404,无法取得权威定义。parse_hysteria 只保证解析本
项目 output_uri 自身生成的链接形态(回环已验证),认不出的查询参数一律忽略而不
报错,不臆造参数语义。
候选方案:待上游文档可访问后核实并补全;或维持「回环保证」的现状。
决策理由:选「维持现状」。规范拿不到就是拿不到 —— 此时任何「补全」都是编造 参数语义,正是「不猜」纪律要禁止的。已实测的回环保证(本包导出 → 本包导入不失真) 是本项目唯一能负责的边界,如实写进文档即可。
2.4 http 不加入任何 UI 协议列表
状态:确认为已知限制([2.6.16-r1] 决策,方案 A) —— 不改代码,写入用户文档
(README「已知限制」)。
依据:规格 §25 的权威协议表恰好是 10 个协议、不含 http。node.lua 的
M.PROTOS 实测为 10 项(vmess / vless / trojan / shadowsocks / ssr / hysteria2 /
tuic / hysteria / wireguard / socks),确实不含 http。它可由 Clash YAML / JSON
配置导入并正常导出(凭据已修),但不作为表单可选项。
候选方案:A. 维持现状(http 属「可导入可导出但不可手录」);B. 加入 UI 列表
(需先确认规格 §25 是否有意排除)。
决策理由:选 A。规格 §25 是否有意排除 http 未核实,在核实之前把它加进
UI 就是替规格做决定;而现状并无功能损失(能导入、能导出),只是不能手工新建,
写进文档说明即可。
2.5 parser_yaml.lua 为死代码(已删除 [2.6.11-r1])
依据:parser.lua:6 require 了它,但全文对 parser_yaml. 的调用次数实测为
0。其中留有同样未归一的 socks5 映射。
影响:不影响运行,属清理项。
候选方案:A. 删除文件与 require;B. 保留(无害)。
2.6 AmneziaWG 3.0 / 3.1 新增的 9 个字段不被 .conf 解析器接受
字段:HeaderProtectionKey、ContentPaddingAddition、RekeyAfterTime、
RekeyTimeout、RejectAfterTime、KeepaliveTimeout、MaxHandshakeAttempts、
RandomTrailers、DisableCookies。
依据:实测这 9 个名字在 parser.lua 中一处都不出现 —— 即 .conf 解析器
按已知键白名单映射,这 9 个键落在白名单外,导入即丢弃;但它们会从 Clash / JSON /
wireguard:// 导入路径原样透传。
影响:同一节点经 .conf 导入与经 JSON 导入,得到的字段集不一致。
状态:已修复([2.6.10-r1] 复核)。parser.lua 的 .conf 键映射表已收录全部
9 个 v3.0 / 3.1 键(headerprotectionkey → header-protection-key …
disablecookies → disable-cookies),并补入了 v1.5 的 s3/s4/i1..i5/j1..j3/itime。
其中 random-trailers / disable-cookies 是布尔字段,按 amneziawg-tools 的
parse_bool(只认 on/off 或十进制数)解析,非法值丢弃而不是原样透传 ——
留着会让 mihomo 解析该字段时报错。本条无需再决策。
三、更早的遗留项(2.5.x 记录,一并归档)
来源:CHANGELOG [2.5.1-r1] / [2.5.0-r1] 的「已知限制」。均为已确认、未修复。
3.1 LuCI 里 amnezia-wg-option 仍是单个 JSON 文本框
状态:确认为已知限制([2.6.16-r1] 决策) —— 不改代码,写入用户文档
(README「已知限制」)。
依据:node.lua 的 PROTO_FIELDS.wireguard 中 amnezia-wg-option 是单个
字段(实测该数组共 13 项,amnezia-wg-option 占其一),因此前端渲染成一个文本框,
需用户手写 JSON。数据本身能正确往返,但录入体验不佳。
候选方案:逐字段化(属新功能,按版本约定应进入 minor 版本)。
决策理由:数据往返正确、无功能损失,仅是录入体验;逐字段化属新功能, 不适合混在缺陷修复批次里。写进文档说明录入方式即可。
3.2 wget 后端的下载体积上限是下载后判断
状态:确认为工具限制([2.6.16-r1] 决策) —— 无法在代码层消除,写入用户文档
(README「已知限制」)与本文。
依据:busybox wget 没有「下载前限流」的选项(已核对 busybox 1.37 --help,
不存在 --max-filesize 之类的等价物)。响应体虽被丢弃,但请求已经发出。
实际影响有限的原因:fetch_wget 在体积超限时会丢弃响应体并报错
(http.lua:size > max → os.remove(tmp) + 返回错误),因此超限内容不会进入
解析流程,也不会落盘留存。剩下的差异只是「流量已经消耗」——这属于工具能力问题。
候选方案:改用 curl(若有,curl 路径有 --max-filesize,是下载中截断)或
维持现状并在文档说明。
决策理由:不改代码。curl 后端本就已经用 --max-filesize 做了下载中限制;
wget 后端受 busybox 能力所限做不到,自实现 HTTP 客户端属大改动、收益与风险不成
比例。如实写进文档。
3.3 改名规则不支持括号内的「或」(a|b)
依据:Lua 模式无 alternation 语义,按字面处理。
候选方案:A. 维持现状并文档说明;B. 实现一个简单的 alternation 展开。
3.4 check_public 在无 DNS 解析能力时放行(fail-open)
依据:http.lua 实测代码为:
local ips = resolve(host)
if not ips then
-- 无 DNS 解析能力:放行,交由下载工具处理(尽力而为)
return true
end
即无 nixio 时任意主机名放行。另存在解析与下载之间的 TOCTOU 窗口。
状态:已修复([2.6.8-r1]),但该修复本身引入了回归,已于 [2.6.12-r1] 重做。
[2.6.8-r1] 把「解析不出 IP」一律改成 fail-closed,前提是「本包依赖
luci-lua-runtime → 硬依赖 luci-lib-nixio → 解析失败只意味着真的解析不了」。
这个前提在用户设备上不成立:resolve() 当时只有 nixio 一条路,
nixio 取不到时恒返回 nil,于是所有域名订阅都被拒,报「无法解析目标主机名」
(用户实测 2.6.7-r1 正常,2.6.8-r1 起四个订阅全部失效)。根因是把
「本机没有解析手段」与「这个域名解析不出来」合并成了同一个 nil。
[2.6.12-r1] 的做法:
resolve()多级回退nixio→ busyboxnslookup,并返回ips, have_resolver;- 有解析手段却解析不出来 → 仍 fail-closed(本条要堵的绕过口继续堵死);
- 完全没有解析手段 → 放行,但返回
unverified = true; - 下载层用 curl 的
%{remote_ip}在连接建立后复核实际对端地址, 并把unverified且拿不到对端 IP 视为拒绝。
TOCTOU 窗口(解析与下载之间)也由此关闭:%{remote_ip} 就是真正连上的那个 IP,
免疫「预检时解析到公网、连接时解析到内网」的 DNS rebinding。
[2.6.16-r1] 补完:wget 后端此前仍是 fail-open。
[2.6.12-r1] 的「连接后复核」只对 curl 后端成立 —— 它靠 %{remote_ip} 拿到
真实对端地址。wget 后端没有任何等价物:busybox wget 拿不到对端 IP,
-S 日志里的重定向链也只能事后看(拦不住已经发出去的请求)。于是在
「本机没有解析手段」这种设备上,wget 路径的「预检放行」之后不存在任何一处校验
—— 等于完全没有 SSRF 防护,而这条路径此前是静默的。
现已按与 verify_peer_ip 相同的处置收敛:校验不了就拒绝。
fetch_wget 在 opts.unverified 为真时直接返回错误
("本机无 DNS 解析能力,wget 后端无法校验目标地址,已拒绝下载(安装 curl 后重试)"),
不再尝试下载。
影响面(如实记录):仅影响「本机无任何 DNS 解析手段(nixio 不可用且无 nslookup)
且 目标为域名 且 下载后端为 wget」的设备 —— 这类设备上的域名订阅会失败,
提示安装 curl。字面 IP 目标不受影响(check_public 对 IP 提前返回,不产生
unverified);有解析手段的设备也不受影响。这是有意的取舍:本包依赖
luci-lua-runtime,正常安装的设备上 nslookup 可用,属于罕见组合;而放行的代价是
一个完全无防护的 SSRF 入口。
回归测试:tests/dns_fallback_test.lua 用例 H(无解析器 + wget → 拒绝;桩会写入
evilbody,确保断言不是因「内容为空」而误过)与 H2(对照组:有解析器 → wget 正常
下载)。反向验证:修复前 H 有 3 条断言失败。
本条已了结,无需再决策。
3.5 wget 路径的重定向校验是事后的
状态:确认为工具限制([2.6.16-r1] 决策) —— 无法在代码层消除,写入用户文档
(README「已知限制」)与本文。
依据:busybox wget 无 --max-redirect(已核对 busybox 1.37 --help);响应体虽
被丢弃但请求已经发出,且无法限制下载体积。
已在代码层做到的部分([2.6.16-r1] 复核):M.validate_redirect_chain(url, log)
会从 -S 日志里按出现顺序提取整条重定向链的 Location,对每一跳重新做
check_public,任一跳指向内网/保留地址即整体拒绝并丢弃响应体。因此
「重定向到内网」不会被放行 —— 缺的只是「在发出下一跳请求之前拦住它」。
与 3.4 的区别:3.4 是「根本没有校验」(已修,改为拒绝);本条是「校验存在但 发生在请求之后」。后者不会让内网内容进入解析流程,只是多发出了一跳请求。
候选方案:与 3.2 一并处理(改用 curl 或自实现 HTTP 客户端)。curl 路径用
--max-redirs 0 自行逐跳跟随,每跳都在发出前校验,不受此限制。
决策理由:不改代码。curl 后端已是「发出前校验」;wget 后端受 busybox 能力所限, 自实现 HTTP 客户端属大改动。如实写进文档。
四、P3:输出层代码级复审([2.6.10-r1] 已修复)
对 8 个输出模块与 output.lua 做了逐行复审,按「生成的配置能否被目标客户端加载」
这一条标准筛出 11 项缺陷。全部已修复、已补回归测试、并已对 HEAD 反向验证
(tests/output_layer_fixes_test.lua 30 条断言、tests/ss_plugin_test.lua 25 条
断言在修复前失败,修复后全绿)。逐项记录如下。
| 编号 | 位置 | 缺陷 | 后果 |
|---|---|---|---|
| F1 | output_clash_meta |
ws-opts: 写在 if node.path 内,只有 host 的 ws 节点输出悬空的 headers: |
YAML 非法 / Host 被忽略,ws 握手被拒 |
| F2 | output_v2ray |
空传输层编码成 [] 而非 {} |
Xray UnmarshalTypeError,拒绝启动 |
| F3 | output_clash_meta |
节点名与其它节点 / 策略组名 / 保留名冲突时不消解 | mihomo proxies 重名,拒绝加载整份配置 |
| F4 | output_clash_meta |
esc_yaml 未引用以 % ! - 开头的标量 |
非法 YAML,mihomo 解析失败 |
| F5 | output_formats |
QX 的 vless 分支不输出 obfs / tls 参数 | 客户端按明文 tcp 连 ws 端口,必然失败且不报错 |
| F6 | output_formats |
Surge 家族的 vless 分支不输出 ws 传输层 | 同上 |
| F7 | output_formats |
参数值里的逗号把凭据静默截断(password=pa,ss → password=pa) |
静默发错凭据 |
| F8 | output_uri |
IPv6 字面量地址未加方括号 | authority 无法解析 |
| F9 | output_clash_meta |
amnezia-wg-option 子键未转义 |
键名里的 : / 引号写坏 YAML 映射 |
| F10 | output.lua |
content_type_for / extension_for 对空 target 落空("" 在 Lua 里是真值) |
正文与响应头 / 文件名后缀不一致 |
| F11 | 全链路 | shadowsocks 的 SIP003 plugin 被三个输出模块静默丢弃;表单保存时被清空 |
带 obfs / v2ray-plugin 的节点导出后连不上;界面上编辑一次即永久丢失插件配置 |
F11 的处置依据(均核对上游源码 / 文档,非推测):
- SIP002:
SS-URI = "ss://" userinfo "@" host ":" port [ "/" ] [ "?" plugin ] [ "#" tag ], 插件参数整体做百分号编码。 - sing-box:
shadowsocks出站只有plugin(字符串,官方文档明确 "Only two are supported: obfs-local and v2ray-plugin")与plugin_opts(SIP003 原始参数串,原样透传)。其余插件名会被拒绝,故按白名单过滤。 - mihomo:
plugin-opts是映射而非字符串,且名称与参数名都要翻译 (obfs-local/simple-obfs→obfs,参数obfs→mode、obfs-host→host)。adapter/outbound/shadowsocks.go对这两类插件是强校验的:obfs 的 mode 不在{tls,http}里报"ss %s obfs mode error",v2ray-plugin 的 mode 不是websocket同样报错 —— 都会让整个 outbound 构造失败,进而拒绝加载整份配置。因此参数 不全时宁可整个不输出插件,也不能输出一个必然被拒绝的组合。
顺带修正的既有测试:tests/output_formats_test.lua 原断言
tuic alpn array joined 期望 alpn=h3,h2。多值 alpn 在 Surge 家族的行语法里
表达不了(逗号分隔的 key=value,无转义),F7 的通用逗号检查会因此丢掉整个节点。
处置:alpn 是协商提示而非凭据,缺省时客户端用服务端给出的列表 —— 所以只省略
该参数、保留节点。断言已按此契约更新。
五、其余遗留项的修复建议(按推荐优先级排序)
实施记录([2.6.11-r1]):下表中 1.1 / 1.3 / 1.4 / 1.5 / 2.5 五项已按推荐方案
实施完毕,3.3 按方案 A 补了用户文档说明。每项都补了自包含回归测试,并对 HEAD
做了反向验证(新断言在修复前失败、修复后全绿):
| 项 | 回归测试 | HEAD 上失败断言数 |
|---|---|---|
1.1 mkdir 锁 |
tests/list_lock_test.lua(39 条) |
18 |
1.3 sing-box transport |
tests/singbox_transport_test.lua(28 条) |
18 |
1.5 QX tag= 逗号 |
tests/qx_tag_comma_test.lua(24 条) |
9 |
1.4 / 2.5 是删除死代码,无新增断言;1.4 顺带修复了 tests/p2_batch7_test.lua
中对已删模块的依赖(改为直接验证 util.uuid)。
实施中修正了两处推荐方案本身的疏漏,记录如下:
- 1.1:
with_list_lock最初把锁目录写成常量M.LOCK_DIR。测试普遍在require之后改写core.DATA_DIR,常量不会跟着变 —— 于是测试会去锁真实的/etc/substore。改为由M.DATA_DIR现算。另一处:全新安装时DATA_DIR尚不存在,mkdir <DATA_DIR>/.lock失败,而失败在lock_acquire眼里等同于「他人持锁」, 症状是第一次保存订阅就报「正被另一个进程修改」;已在取锁前先ensure_dirs()。 - 1.1:
M.merge是只读的(只读各订阅节点、过滤排序后返回数组), 包进锁后一旦取锁失败会返回nil顶掉原本的数组,把「拿不到锁」变成调用方眼里的 「没有数据」—— 比不加锁更糟。已移出包装列表。
| 项 | 推荐方案 | 理由 | 前置条件 |
|---|---|---|---|
| 1.1 H10 无锁 | ✅ C:mkdir 原子锁 + 陈旧锁回收 |
纯 Lua、无新依赖,本机可测(桩掉 os.execute / os.time);崩溃残留锁由锁目录 mtime 自愈 |
已定阈值 util.LOCK_STALE = 60 秒 |
| 1.3 sing-box 读取侧 transport 丢失 | ✅ 已修复(补 transport 解析) |
与 F5/F6/F11 同类:静默丢传输层 → 节点连不上;影响面更大 | 字段映射已核实(见 1.3 正文) |
| 1.4 死代码 + README 声明不符 | ✅ 已删除死代码(并已随 [2.6.10-r1] 精简 README 声明) |
删去了「协议转换:任意协议 → 任意协议」的过度声明 | 已确认无任何 require |
1.5 QX tag= 逗号 |
✅ B:整体丢弃 | 见上文 | 已实施;QX 实际行为仍未实测(无 QX 环境),按「宁可丢节点也不输出损坏行」处理 |
| 2.1 混合格式文本导入 | ✅ B+:本地导入时明确报错([2.6.16-r1]) |
见 2.1 正文;远程订阅路径刻意不动 | 无 |
2.2 hysteria v1 up/down |
✅ A:确认为已知限制 + 文档([2.6.16-r1]) |
补字段属新功能,且单位/缺省值规格未核实 | 无 |
| 2.3 hysteria v1 URI 规范 | ✅ 维持现状 + 文档([2.6.16-r1]) |
上游规范无法核实,按「不猜」原则不动 | 找到权威规范后再定 |
2.4 http 不进 UI |
✅ A:确认为已知限制 + 文档([2.6.16-r1]) |
规格 §25 是否有意排除未核实,现状无功能损失 | 无 |
2.5 parser_yaml.lua 死代码 |
✅ 已删除 | 死代码会被后续审计反复重新评估,成本高于收益 | 已确认只被 parser.lua 一行 require 引用、且从未使用 |
| 3.1 AWG 逐字段化 UI | ✅ 确认为已知限制 + 文档([2.6.16-r1]) |
数据往返已正确,仅录入体验 | 无 |
| 3.2 / 3.5 wget 体积与重定向 | ✅ 确认为工具限制 + 文档([2.6.16-r1]) |
busybox wget 无对应选项;curl 路径已无此问题 | 无 |
3.3 改名规则 (a|b) |
✅ A:维持现状 + 文档说明 | Lua 模式无 alternation;自实现展开要处理嵌套与字符类,收益低 | 已在 README.md / README.en.md 的「使用方法」补说明(顶层 | 才是「或」,(...) 内按字面) |
六、本轮实施记录([2.6.16-r1])
本轮按「处置优先级 1–8」实施,逐项记录如下。每项都补了自包含回归测试,并对
HEAD 做了反向验证(新断言在修复前失败、修复后全绿)—— 反向验证是必须的:
只证明「修复后测试通过」无法排除「这条断言本来就不会失败」。
| 优先级 | 项 | 位置 | 回归测试 | HEAD 上失败断言数 |
|---|---|---|---|---|
| 1 | wget 后端 SSRF 缺口(见 3.4) | http.lua fetch_wget |
tests/dns_fallback_test.lua 用例 H / H2 |
3 |
| 2 | 删除部分失败时漏写 cron | controller/admin/substore.lua action_delete |
tests/controller_robustness_test.lua |
1 |
| 3 | 混合格式静默丢弃(见 2.1) | parser.lua detect_all + parse_local |
tests/parser_mixed_format_test.lua(70 条) |
探针:nodes=1, err=nil |
| 4 | 写入失败被忽略 | core.lua M.remove / M.ensure_token |
tests/data_integrity_test.lua |
5 |
| 5 | post_ok() 空 token 放行(见 1.2 M28) |
controller/admin/substore.lua |
tests/controller_robustness_test.lua M28 段 |
7 |
| 6 | 无 ACL(见 1.2 M29) | rpcd/acl.d + menu.d + Makefile |
tests/acl_menu_test.lua(19 条) |
13 |
| 7 | A4–A7 记文档(2.2 / 2.3 / 2.4 / 3.1) | README / 本文件 | —(无代码改动) | — |
| 8 | wget 体积与重定向记文档(3.2 / 3.5) | README / 本文件 | —(无代码改动) | — |
优先级 2 的详细依据(该缺陷不在本文件原有清单内,是实施前代码级审计新发现的):
action_delete 原先只在全部删除成功时调用 core.write_cron(),部分失败时
直接 return back_to_list(...) 跳过了它。后果:被删掉的订阅的 cron 行仍留在
crontab 里,substore-cron.sh 会拿着已不存在的 id 反复执行,每次都以非 0 退出
(脚本末尾有 FAILED 判断),在日志里刷失败、并让监控误报。现改为
只要有订阅真的被删掉(removed > 0)就重写 cron,与成功/失败分支无关。
优先级 4 的详细依据(同样是本轮审计新发现):
core.lua 的 save() 返回 util.atomic_write(...) 的结果 —— 失败时是
false, err,而 M.remove / M.ensure_token 此前丢弃了返回值:写盘失败时
M.remove 仍报成功(订阅文件已删、索引没更新 → 索引指向不存在的订阅),
M.ensure_token 会把一个没有落盘的 token 返回给调用方(页面显示 token,
实际不存在)。现改为透传失败与原因。回归测试用 util.atomic_write 猴补丁注入
写失败,断言:remove 报失败且保留 nodes 文件、ensure_token 返回 nil, err、
失败恢复后能正常写入。
七、2.7.2 审计新发现
本节是给 AnyTLS + Reality 支持做代码级审计时顺带确认的问题。 每条都给出实测探针或上游文档依据,无推测项。
状态(决策已定,按轮次实施):
| # | 决策 | 状态 |
|---|---|---|
| 7.1 | A:引入 FAMILY_CAPS 按客户端能力表过滤 |
已实施(第二轮,见下) |
| 7.2 | A:真正实现 Egern YAML 生成器 | 已实施(第三轮,见下) |
| 7.3 | C:只对带 public-key 的 vmess / vless 分叉 qx_tls |
已实施(第二轮,见下) |
| 7.4 | A:Loon 位置参数化(并给 public-key 加双引号) |
已实施(第二轮,见下) |
| 7.5 | 修复 | 已修复(第一轮,见下) |
| 7.6 | 修复 | 已修复(第一轮,见下) |
| 7.7 | A:后端错误串 msgid 化 | 已实施(第四轮,见下) |
| 7.8 | A:删除 age.lua / age_test.lua |
已删除(第一轮) |
| 7.9 | (a)(b)(c)(d) 全部实施;(e) 具名保持保守、Loon 位置参数放行(第八轮修订);(g)(h) 实施;(i) 保持现状;(j) 实施(Surge/SurgeMac) | 已实施(第五~八轮,见下) |
7.5 / 7.6 的修复见本节末尾「7.5 / 7.6 修复记录」, 7.1 / 7.3 / 7.4 的实施见「第二轮修复记录」, 7.2 的实施见「第三轮修复记录」, 7.7 的实施见「第四轮修复记录」, 7.9 的 (a)–(e) 见「第五轮修复记录」,(g)(h) 见「第六轮修复记录」,(j) 见「第七轮修复记录」,(e) 的修订见「第八轮修复记录」。
| # | 问题 | 位置 | 依据 | 影响 |
|---|---|---|---|---|
| 7.1 | Surge 格式会为 VLESS 节点生成代理行,而 Surge 的协议清单里没有 VLESS | output_formats.surge_config |
Surge 手册(manual.nssurge.com)协议清单无 VLESS;探针见下 |
节点必然不可用(Surge 对「不认识的代理行」的处置未获官方证实 —— 官方只说明过无法识别的 section 会原样保留且不报错;此处按「不输出客户端读不懂的东西」处理,与丢弃 wireguard / ssr 同一约定)。已按 A 实施:新增 FAMILY_CAPS,Surge / Surfboard / SurgeMac 丢 vless。(第二轮时 Egern 也在表里丢 ssr;第三轮 7.2 实施后 Egern 有了自己的模块,能力判定随之搬进 output_egern.lua 的 EGERN_KEY。) |
| 7.2 | Egern 格式输出的是 Surge 逗号行,而 Egern 的配置是 YAML | output_formats.to_egern(已删除) |
egernapp.com/docs/configuration/example/ 与 .../proxies/;探针见下 |
选 Egern 格式导出的内容 Egern 读不了。已按 A 实施:新增 output_egern.lua(真正的 YAML 生成器),output_formats.to_egern 与其 M.generate 分支一并删除;后缀 .conf → .yaml |
| 7.3 | QX 的 vmess / vless 用 tls-host= + tls-verification=true 表示 TLS,官方 sample.conf 用 obfs=over-tls / obfs=wss + obfs-host |
output_formats.qx_tls |
crossutility/Quantumult-X 的 sample.conf;探针见下 |
见 7.3 的详细说明 —— 会让新加的 QX vmess/vless Reality 公钥不生效 |
| 7.4 | Loon 的 trojan / vmess / vless 凭据在官方文档里是位置参数,本生成器一律写具名参数(只有 anytls 按 flavor 分对了) | output_formats.surge_line |
nsloon.app/docs/Node/ 的示例行 |
Loon 是否同时接受具名写法无文档依据;若不接受则这几类节点导出到 Loon 后连不上。已按 A 实施:Loon 改位置参数,Surfboard 的同类问题见 7.9 |
| 7.5 | parser_surge 的行拆分不是引号感知的(rest:gmatch("[^,]+")) |
parser_surge.lua 的 split_fields |
代码级:位置参数里含逗号的值会被切断 | 别人给的 Loon / QX 配置里带逗号的密码被静默截断(生成端已有「含逗号就整条丢弃」的防护,解析端没有对应防护) |
| 7.6 | transport=ws 未映射到 net |
parser_surge.parse_surge_line 的 transport= 分支 |
只认 ws=true(Surge 旧写法)与 obfs=ws(QX);nsloon.app/docs/Node/ 用 transport=ws + path= + host= |
Loon 的 ws 节点导入后 net=tcp,path / host 全丢 → 导出到任何格式都按 tcp 连,握手失败且不报错 |
| 7.7 | core.lua 47 / http.lua 47 / parser.lua 9 / util.lua 5 / output_wireguard_conf.lua 3 / node.lua 2 |
扫描脚本(去注释后提取含 CJK / 全角的字符串字面量),见下 | 控制器文案已接入 i18n,但这些来自后端的失败原因经 ?err= 原样显示,英文界面下仍是中文。已按 A 实施:全部改为语言中立的英文 msgid,组合消息用 msg.lua 的分隔符机制,翻译只在显示边界发生(详见「第四轮修复记录」) |
|
| 7.8 | root/usr/share/substore/age.lua 与 tests/age_test.lua 未被 git 跟踪,且 age.lua 未被任何模块 require |
仓库根 | grep -rn require 无引用 |
未随包发布;留在工作区会被后续审计反复重新评估 |
| 7.9 | output_formats.surge_line / parser_surge.parse_surge_line |
nsloon.app/docs/Node/;manual.nssurge.com;getsurfboard.com |
逐条见下「7.9 的明细」。已按 (a)(b)(c)(d) 全部实施、(e) 具名保持保守而 Loon 位置参数于第八轮改为放行:Loon 的 TLS 开关改 over-tls(Trojan 除外)、UDP 改 udp、Surge 家族补 encrypt-method、Loon 的 ss/ssr/hysteria2 改位置参数;解析端同步跟进以保住回环(详见「第五轮修复记录」)。实施中新发现三条待决策项,见下「第五轮新发现」;其中 (g)(h) 已于第六轮实施、(i) 决定保持现状,另发现 (j) Surge 家族同样没有 Hysteria v1,已于第七轮实施(Surge / SurgeMac 丢弃,Surfboard 因无证据保留) |
7.7 的统计口径与例外(修复前必读)
本节原先写的「约 103 处(grep -c 'return nil, ".*[^ -~]')」口径有误:
那条 grep 按行匹配,既会把中文注释算进去,又会漏掉「只含全角标点」的字面量
(如 "," 这类),逐文件数字也对不上。下面是重新核对的口径。
统计方法:先去掉注释(-- 行注释与 --[[ ]] 块注释),再提取字符串字面量
(" 与 '),保留其中含 CJK / 全角字符的(UTF-8 首字节 E3–E9 或 EF)。
结果 113 处:core.lua 47 / http.lua 47 / parser.lua 9 / util.lua 5 /
output_wireguard_conf.lua 3 / node.lua 2。
其中 76 处是直接的 return nil, "…" / return false, "…" 形式;另外 37 处是
同一类文案经别的路径到达用户,例如:
core.lua的M.save_meta(id, { error = "…" })—— 同一个串既写进 meta (列表页用it.error显示)又被return出去,两处必须用同一个 msgid;:format()/..拼接出来的串(如"HTTP 错误 "、"响应超过大小限制 (");- 表项(
FORMAT_LABELS)与视图直接渲染的标签(human_duration)。
三处不能照搬 msgid 化:
| 位置 | 内容 | 为什么特殊 |
|---|---|---|
node.lua:377 |
"[^,%s,]+" |
这是正则字符类,全角逗号是模式的一部分,翻译会直接破坏关键词拆分 |
util.lua:658 M.human_duration |
"已过期" / "%d天" / "%d小时" / "%d分钟" / "不足1分钟" |
由视图 view/substore/form.htm:39 直接渲染,不走 ?err=;应在视图侧翻译(或 msgid 化后在视图 _()) |
parser.lua:103 FORMAT_LABELS |
"URI 链接" / "Surge/Loon 配置" |
是格式显示名,被插进「混用多种格式」的错误文案里 |
关键设计点:meta.error 会被 it.error 原样显示,所以 msgid 必须语言中立
(英文),翻译只发生在显示边界(视图 / 控制器),后端模块不得 require("luci.i18n")
—— substore-cron.sh 会在独立的 lua 进程里跑 core.sync,那里没有 LuCI 环境。
7.9 的明细(第二轮实施时新发现,第五轮已实施)
第二轮为实施 7.4-A 去核对 Loon 的节点行文档(nsloon.app/docs/Node/),顺带发现
Loon 与 Surge 家族的不一致远不止「凭据位置」。以下每条都对照官方文档原文,
没有推测项;本轮按用户的轮次安排只做决策里点名的 7.4-A,其余记录在此待决策。
第五轮(2.7.2-r6)已按 (a)(b)(c)(d) 全部实施、(e) 保持保守,各条下方的
「→ 实施」注明落点;(e) 已于第八轮(2.7.2-r9)部分修订(具名参数保持保守、
Loon 的引号包裹位置参数改为放行),见「第八轮修复记录」。
(a) Loon 的 TLS 开关写作 over-tls,本生成器写 tls —— 影响最大的一条。
Loon 文档的通用参数表与 Reality 示例都用 over-tls=true:
节点名称 = VMess,服务器,端口,加密方式,"UUID",transport=传输方式,可选参数
… ,public-key="…",short-id=…,over-tls=true
本生成器对 Loon 的 vmess / vless / trojan 一律写 tls=true(Surge 的写法)。
Loon 文档只为传输参数明说了旧写法别名(ws=true ↔ transport=ws、
ws-path ↔ path、ws-headers=Host:域名 ↔ host),没有把 tls 列为
over-tls 的别名,也没有说它会被拒绝。所以「tls=true 在 Loon 里是否生效」
无依据。若被忽略,Loon 上的 vmess / vless / trojan 会按明文连 ——
包括本轮新支持的 Reality 节点(公钥写了、TLS 标志却没生效)。
这条不修,7.4-A 与 Reality 支持在真机 Loon 上的收益都要打折。
→ 实施(第五轮):surge_line 引入 tls_flag(is_loon and "over-tls=true" or "tls=true"),vmess / vless 用它。Trojan 例外:复核 Loon 文档的明文与
Reality 两条 Trojan 示例,都没有任何 TLS 键 —— Trojan 本身就建立在 TLS 之上,
Loon 不再要这个开关,所以 Loon 的 Trojan 行整个不写 TLS 参数(Surge 家族的
tls=true 是文档要求的,保持不变)。解析端早已同时认 tls 与 over-tls
(parser_surge.lua),回环不受影响。
(b) Loon 的 UDP 参数写作 udp,本生成器写 udp-relay —— 同上,Loon 文档是
udp=true。影响较小(UDP 转发失效,节点本身还能用)。
→ 实施(第五轮):udp = is_loon and "udp=true" or "udp-relay=true"。
(c) Surge 家族的 vmess 不输出 encrypt-method —— Surge 手册的 vmess 页有
encrypt-method 参数,取值 aes-128-gcm(默认)或 chacha20-ietf-poly1305。
本生成器只写 username=,所以 cipher 是 chacha20 的节点会被 Surge 按默认的
aes-128-gcm 去连 —— 静默用错算法,与 F5/F6 同一类失效。注意拼写差异:
统一模型写 chacha20-poly1305,Surge / Loon 都写 chacha20-ietf-poly1305
(Loon 侧本轮已用 LOON_VMESS_CIPHER 映射,Surge 侧没有对应参数可写)。
→ 实施(第五轮):新增 SURGE_VMESS_CIPHER(aes-128-gcm → aes-128-gcm、
chacha20-poly1305 → chacha20-ietf-poly1305),在非 Loon 的 vmess 分支输出
encrypt-method=。取值不在 Surge 清单里时(zero / none / auto /
aes-128-cfb)不写该参数、节点保留 —— 写一个客户端读不懂的取值比不写更糟。
(d) Loon 的 shadowsocks / ssr / hysteria2 凭据也是位置参数 —— Loon 文档:
节点名称 = Shadowsocks,服务器,端口,加密方式,"密码",可选参数
节点名称 = ShadowsocksR,服务器,端口,加密方式,"密码",protocol=协议,…
节点名称 = Hysteria2,服务器,端口,"密码",可选参数
本生成器对这些协议一律写具名参数(encrypt-method= / password=)。
→ 实施(第五轮):Loon 分支下 ss / ssr 的加密方式与密码、hysteria2 的密码
一律改位置参数(密码走 loon_positional 加双引号);ssr 的 protocol= / obfs=
及其参数仍是具名。解析端同步跟进(parser_surge.parse_surge_line)——
其中 SSR 此前根本没有取字段的分支,任何写法导入后加密方式、密码、协议插件、
混淆方式全是 nil,是这一轮顺带修掉的既有缺陷。
(e) Loon 的双引号确实能保住逗号 —— 文档原文「参数值中含有英文逗号时, 请使用双引号包裹」。本文件此前注释写的「Loon 的那对引号只是标记,值里的逗号 照样是分隔符」是错的,已在本轮更正。当前实现仍按「含逗号就整条丢弃」处理 (保守:本行的具名参数一律不带引号,两种约定混用会让行为依赖客户端实现), 代价是极少见的「密码里带逗号」的节点在 Loon 上被丢弃。
→ 部分修订(第八轮,2.7.2-r9):既然引号确实能保住逗号,上述保守决策的 依据(「引号救不了逗号」)就不成立。第八轮只推翻位置参数那一半 —— Loon 的 引号包裹位置参数放行含逗号的值;具名参数仍按「含逗号就丢弃」(Surge 家族的 引号语义未获文档证据,且解析端不对具名值 unquote)。判据是「值是否被引号完整 包裹」而非「是否 Loon」,Surfboard 的裸位置参数因此自动落在丢弃一侧。详见 「第八轮修复记录」。
(f) 更正:Surfboard 的 trojan / vmess / vless 凭据是具名写法,不是位置参数 ——
本节 7.4 行原先把 Surfboard 与 Loon 并列,是误判。复核 getsurfboard.com 的
vmess 页:其 Format 模板写 {username} 位置,但实际示例与参数表都是 key=value
(ProxyVMess = vmess, 1.2.3.4, 8000, username=0233d11c-…);Surge 手册的 vmess /
trojan 页同样写 username= / password=。所以 Surge / Surfboard / SurgeMac 的
具名写法是对的,7.4 只该改 Loon —— 7.4 行已按此更正。
(e) 实施说明(第五轮):保持不变。生成端仍按「位置参数或具名值里含英文逗号
就整条丢弃」处理,并在 tests/output_formats_test.lua 钉了一条断言防止日后被
无意改成半截输出。
(e) 修订说明(第八轮):位置参数那一半被部分推翻。第五轮那条断言
(loon comma password drops node)已按新行为翻转成 loon comma password kept (quoted positional),并补了「导出 → 导入拿到同一个密码」的回环断言 —— 放行的
前提就是能原样回环。具名参数那一半保持不变,护栏断言仍在。
第五轮新发现((g)(h) 第六轮已实施,(i) 保持现状,(j) 第七轮已实施)
第五轮实施 (a)–(d) 时对照 nsloon.app/docs/Node/ 全文复核,另发现三处不一致。
每条都只陈述文档写了什么与本实现写了什么,不推测客户端的实际行为。
第六轮按决策实施了 (g)(h),(i) 决定保持现状,实施过程中又发现 (j)(第七轮已实施)。
(g) Loon 没有 Hysteria v1,本生成器会把 hysteria 行输出给它 —— Loon 文档的
节点类型清单里只有 Hysteria2,没有 Hysteria(v1)。而 FAMILY_CAPS 只按
vless / ssr 分客户端能力,hysteria 不在过滤表里,所以统一模型里的 hysteria v1
节点会生成一条 Loon 读不懂的行。
→ 实施(第六轮):FAMILY_CAPS.loon 加 hysteria = false,整条丢弃
(与丢弃 wireguard / ssr 同一约定)。同时把 surge_config 的过滤从逐协议写死的
elseif p == "ssr" and not caps.ssr 改成数据驱动的 elseif caps[p] == false ——
每加一个维度就要再加一条分支,漏掉的那条默认是「保留」,正是本条缺陷的成因。
注意 hysteria 2 是各家通用的,被丢的只有上一代 v1。
(h) skip-cert-verify 写成 1,两家文档都没写这个取值 —— Loon 文档的示例
一律写 skip-cert-verify=false;Surge 手册的 TLS 页只写「Optional, boolean,
default: false」,没有列出可接受的字面量。本生成器一律写
skip-cert-verify=1。1 是否被接受无依据。解析端两种都认(parser_surge
把非 0 / 非 false 都当 true),所以回环不受影响。
→ 实施(第六轮):改写成 skip-cert-verify=true。判据是「Loon 自己的示例写了
false,直接证明 false 是合法字面量、true 同理;而 1 在任何一家文档里都
找不到」。既有测试里没有任何一条断言过生成端的 skip-cert-verify=1
(anytls_reality_test 里那一处是把 =1 当输入喂给解析器,仍然有效)。
(i) 协议名大小写:Loon 文档一律首字母大写,本生成器一律小写 —— Loon 文档写
Hysteria2 / VMess / VLESS / Shadowsocks / AnyTLS;本生成器输出
hysteria2 / vmess / vless / shadowsocks / anytls。Loon 官方定位是
「兼容 Surge 配置」,而 Surge 手册的语法行用的是小写(Name = vmess, <host>, …),
所以小写很可能被接受,但没有文档明说。
→ 决定:保持现状(第六轮)。没有任何证据显示小写会失败,而改动面覆盖全部 协议;若日后确认大小写敏感,应改的是 Loon 分支的类型名映射。
第六轮新发现((j) 第七轮已实施)
(j) Surge 家族同样没有 Hysteria v1,本生成器仍会把 hysteria 行输出给它们 ——
第五轮实施 (g) 时顺带复核了 Surge 的协议清单,发现与 Loon 是同一个缺口:
manual.nssurge.com的 Proxy Protocols 一节列的是 Hysteria 2,没有 Hysteria;- 直接的页面探针:
manual.nssurge.com/policies/hysteria.html→ HTTP 404, 而manual.nssurge.com/policies/hysteria2.html→ 存在(语法行Proxy-Hysteria = hysteria2, 192.168.20.6, 443, password=pwd, download-bandwidth=100)。
也就是说 Surge / SurgeMac 的 hysteria(v1)行同样是一条客户端读不懂的行。
(g) 的决策只点名了 Loon,所以第六轮没有顺手改 Surge 家族 ——
tests/output_formats_test.lua 里有一条断言如实钉住了当前行为
(surge still keeps hysteria v1 (finding j pending)),实施 (j) 时要一并翻转。
Surfboard 未获证据:getsurfboard.com/docs/profile-format/proxy/external-proxy/
与其无斜杠形式本轮都返回 404,没能取到协议清单。
修法:给 FAMILY_CAPS 的 surge / surfboard / surgemac 三行都加 hysteria = false
(过滤逻辑第六轮已改成数据驱动,加一个键即可),或把 hysteria 与 wireguard
一样无条件丢弃。
→ 实施(第七轮,2.7.2-r8):FAMILY_CAPS.surge / FAMILY_CAPS.surgemac 各加
hysteria = false,整条丢弃。被丢的仍然只有上一代 v1 —— Hysteria 2 三家都支持,
两者在 surge_line 里共用同一个输出分支,靠 proto 区分,容易误伤,测试里专门
断言了「丢 v1、留 v2」。
没有给 surfboard 加这一条:文档 404、证据不足,能力表对它保持沉默
(caps[p] == nil 一律保留)。上面那条「三家都加」的修法没有采纳 ——
第七轮的决策是「Surge + SurgeMac 一起丢」,Surfboard 维持现状。
tests/output_formats_test.lua 里留了一条 surfboard keeps hysteria v1 (no evidence to drop) 钉住这点,防止日后有人「顺手统一」。
第六轮那条 finding j pending 的占位断言已按计划翻转成 surge drops hysteria v1。
7.1 / 7.2 / 7.3 的实测探针
修复前([2.7.2-r2] 及更早):
$ lua5.1 -e '... out.generate({vless节点}, "surge", {name="P"}) ...'
[Proxy]
V = vless, 1.2.3.4, 443, username=u, tls=true ← Surge 协议清单里没有 vless
$ lua5.1 -e '... out.generate({vmess节点}, "egern", {name="P"}) ...'
[Proxy]
M = vmess, 1.2.3.4, 443, username=u, tls=true ← Egern 的配置是 YAML,不是逗号行
$ lua5.1 -e '... out.generate({vmess+reality节点}, "qx", {name="P"}) ...'
[server_local]
vmess=1.2.3.4:443, method=none, password=u, tls-host=s.example.com,
tls-verification=true, reality-base64-pubkey=PBK, reality-hex-shortid=SID, tag=R
修复后([2.7.2-r4],7.1 / 7.2 / 7.3 均已实施):
$ ... out.generate({vless节点}, "surge", {name="P"}) ...
[Proxy]
[Proxy Group]
P = select, DIRECT
↑ [Proxy] 段为空:节点被 FAMILY_CAPS 整体丢弃(连成员列表也不引用它)
$ ... out.generate({vmess节点}, "egern", {name="P"}) ...
proxies:
- vmess:
name: M
server: 1.2.3.4
port: 443
user_id: u
security: auto
policy_groups:
- select:
name: P
policies:
- M
↑ 7.2-A:真正的 Egern YAML —— 协议名是映射键、字段 snake_case
(此前 `[2.7.2-r3]` 及更早输出的是 `M = vmess, 1.2.3.4, 443, username=u, tls=true`
这样的 Surge 逗号行,Egern 读不了)
$ ... out.generate({vless+reality节点}, "egern", {name="P"}) ...
proxies:
- vless:
name: V
server: 1.2.3.4
port: 443
user_id: u
transport:
tls:
sni: s.example.com
reality:
public_key: PBK
short_id: SID
↑ 7.2-A:Reality 在 transport.<类型>.reality 里,键名是 public_key / short_id
$ ... out.generate({wireguard节点}, "egern", {name="P"}) ...
proxies:
- wireguard:
name: W
server: 1.2.3.4
port: 51820
private_key: k
peer_public_key: k2
local_ipv4: 10.0.0.2/32
↑ 7.2-A 顺带补上的能力:Egern 的 WireGuard 有独立协议块
(此前走 surge_config 的单行 [Proxy],只能整条丢弃)
$ ... out.generate({ssr节点}, "egern", {name="P"}) ...
proxies: []
policy_groups:
- select:
name: P
policies:
- DIRECT
↑ Egern 的协议清单里没有 SSR(也没有 Hysteria v1):整条丢弃
$ ... out.generate({vmess+reality节点}, "qx", {name="P"}) ...
[server_local]
vmess=1.2.3.4:443, method=none, password=u, obfs=over-tls, obfs-host=s.example.com, reality-base64-pubkey=PBK, reality-hex-shortid=SID, tag=R
↑ 7.3-C:QX 只认这个形式的 TLS 标志
(此前写的是 tls-host= + tls-verification=)
$ ... out.generate({vmess+reality节点}, "loon", {name="P"}) ...
[Proxy]
R = vmess, 1.2.3.4, 443, auto, "u", tls=true, sni=s.example.com, public-key="PBK", short-id=SID
↑ 7.4-A:位置参数「加密方式, "UUID"」 ↑ 公钥按文档加双引号
7.3 详细说明(唯一一条会影响本次新功能的)
sample.conf 的说明原文(crossutility/Quantumult-X 仓库,[server_local] 前):
…if the corresponding line (socks5:
over-tls=true, http:over-tls=true, trojan:over-tls=trueorobfs=wss, anytls:over-tls=true, … vmess:obfs=over-tlsorobfs=wss, vless:obfs=over-tlsorobfs=wss) contains thereality-base64-pubkeyparam, then the standard TLS will be replaced with the Reality.
即:QX 里 vmess / vless 的「TLS 标志」写作 obfs=over-tls(或 obfs=wss),
trojan / anytls 才写 over-tls=true。而 qx_tls() 对所有协议统一输出
tls-host= + tls-verification=true(探针第三段可见)—— 于是 vmess / vless 的
reality-base64-pubkey 很可能被 QX 忽略,节点退回普通 TLS。
本次没有改 qx_tls():它是既有实现,改动会让所有 QX vmess / vless TLS
节点的输出形态变化(不只是 Reality 节点),需要单独决策与回归;而且
tls-host / tls-verification 在 QX 里是否对 vmess / vless 同样有效没有找到
官方依据(sample.conf 里只用 obfs= 形式,但「未出现」不等于「无效」)。
本次只保证:只要 TLS 标志生效,公钥就已经写在那行上了(qx_reality() 覆盖
vmess / vless / trojan / anytls 四类,与 sample.conf 的 Reality 条目一致)。
处置候选:A. 维持现状 + 本节记录;B. qx_tls() 按协议分叉(vmess / vless 走
obfs=over-tls + obfs-host,trojan / anytls 维持现状),需要同步更新
parser_surge.parse_qx_line 的读取端与 tests/protocol_registry_test.lua。
已按候选 C 实施(第二轮):分叉只针对带 public-key 的 vmess / vless ——
新增 qx_obfs_reality(),net == "ws" 时写 obfs=wss + obfs-uri + obfs-host,
否则写 obfs=over-tls + obfs-host(取 sni)。不带公钥的 vmess / vless 完全不动
(仍走 qx_transport + qx_tls),所以既有节点的输出形态不变,风险面只落在
本次新加的 Reality 功能上。读取端不需要改:parser_surge.parse_qx_line 早就
按 sample.conf 实现了 obfs=over-tls / obfs=wss 的映射(obfs-host 在
over-tls 下落 sni、在 wss 下落 host),往返已在回归测试里断言。
第一轮修复记录(7.5 / 7.6 / 7.8)
7.5 引号感知切分 —— 新增 parser_surge.split_fields()(parser_surge.lua),
parse_surge_line 与 parse_qx_line 的切分都改走它。规则:
- 先数引号个数,奇数则退回旧的
gmatch("[^,]+")。订阅内容不可信,落单的 引号若被当成「开引号」,会把后面的sni=/over-tls=全吞进同一个字段, 比按逗号切更糟。 - 偶数时按引号开合切分,引号内的逗号不再是分隔符;引号本身保留在字段里,
由既有的
unquote()(Loon 位置参数)或原样(QX kv 值)处理。 - 无引号的输入与旧实现逐字符等价:同样按逗号切、同样丢弃空字段、同样 trim。
7.6 Loon 传输参数 —— parse_surge_line 增加 transport= / path= / host=
三个映射(transport=http 按 Loon 文档「会按 WebSocket 处理」落成 ws),放在
ws=true / ws-path / ws-headers=Host: 之后,同一行两种写法都出现时以新写法为准。
回归测试:新增 tests/parser_surge_fields_test.lua(36 条断言),覆盖两个
缺陷的修复点、旧写法回归、无引号输入的逐字符等价、空字段边界、奇数引号边界、
以及「新写法优先」。文件头写明「修复前应当失败」。
反向验证:把 parser_surge.lua stash 掉后跑该文件,得到 12 条 FAIL,症状
与预测完全一致("pa / "p / "uu 被截断的凭据、net=tcp、path=nil、
host=nil、旧写法的 /old 与 old.example.com 胜出);git stash pop 后
0 条 FAIL。全套测试 58 个文件、0 失败。
7.8 删除 —— root/usr/share/substore/age.lua 与 tests/age_test.lua 已从工作区
删除。审计复核发现它们并非「写完没人用」的死代码,而是未完成的半成品:
age.lua 文件头声称实现了 sha256 / hmac-sha256 / hkdf / chacha20 / poly1305 /
x25519 / bech32 / armor / STREAM,实际只写到 M._hkdf_sha256 就停了,末尾留着
-- @@NEXT@@ 续写标记(全仓库仅此一处);tests/age_test.lua 引用的
tests/age_vectors/README.md 根本不存在。两者都未被 git 跟踪,删除即不可恢复,
因此删除前已在仓库外留了一份备份(~/age-lua-wip-backup-2026-10-05/,含
age.lua 与 tests/age_test.lua 两个原文件)。后续若要继续这条线,从该备份恢复即可。
第二轮修复记录(7.1 / 7.3 / 7.4)
全部改动集中在 root/usr/share/substore/output_formats.lua(输出侧),
解析侧一行未改 —— 三处新写法都早就有对应的读取实现,往返由回归测试锁定。
7.1 FAMILY_CAPS(按客户端能力表过滤) —— 新增一张表,键是输出格式名,
值是 { vless = <bool>, ssr = <bool> },只记录各家不一致的两个协议
(其余协议各家都认):
| flavor | vless | ssr | 依据 |
|---|---|---|---|
| surge / surfboard / surgemac | ✗ | ✗ | manual.nssurge.com 的 Proxy Protocols 清单、getsurfboard.com 的 external-proxy 清单 |
| loon | ✓ | ✓ | nsloon.app/docs/Node/ 有独立的 VLESS 与 ShadowsocksR 两节 |
| egern | ✓ | ✗ | egernapp.com/docs/configuration/proxies/ 的协议清单有 Vless、无 ssr |
第三轮 7.2 实施后 egern 已移出这张表 —— 它的配置是 YAML,改由
output_egern.lua的EGERN_KEY决定收哪些协议(上表这一行是第二轮当时的 真实状态,保留作记录)。
surge_config(nodes, group_name, flavor) 改成按这张表丢节点(此前是调用点各传一个
supports_ssr 布尔量 —— 加一个维度就要再加一个参数,调用点一多必然漏传,
而漏传的默认值是「支持」)。五个调用方(surge / surfboard / surgemac / loon / egern)
改为传格式名。
7.3-C QX Reality 的 TLS 标志分叉 —— 新增 qx_obfs_reality(),只对带
public-key 的 vmess / vless 生效:net == "ws" 写 obfs=wss + obfs-uri +
obfs-host,否则写 obfs=over-tls + obfs-host(取 sni)。不带公钥的
vmess / vless 完全不动,所以既有 QX 节点的输出形态不变 —— 风险面只落在本次新加的
Reality 功能上(这是选 C 而不是 B 的全部理由)。读取端 parse_qx_line 早就实现了
obfs=over-tls / obfs=wss 的映射,无需改动。
7.4-A Loon 位置参数化 + 公钥加引号 —— 新增 loon_positional(),Loon 的
trojan / vmess / vless 凭据改写成端口之后的带引号位置参数(Trojan,h,p,"密码"、
VLESS,h,p,"UUID"、VMess,h,p,加密方式,"UUID"),Reality 的 public-key 按文档
加双引号(short-id 不加)。Surge / Surfboard / SurgeMac 维持具名写法 ——
复核确认它们本来就是对的(见 7.9 的 (f))。
实施中发现并一并修掉的两个本轮改动自身会引入的问题:
- Loon 的 VMess 加密方式拼写不同 —— 统一模型的
chacha20-poly1305在 Loon 里 写作chacha20-ietf-poly1305,模型的zero是 Xray 专用(Loon 清单里没有)。 照抄会让这两类节点在 Loon 上加载失败,因此新增LOON_VMESS_CIPHER映射, 未列出的取值退回auto。这是位置参数化必须配套的一步,不是额外功能。 - 带引号的位置参数遇到值里的双引号 ——
'"'..v..'"'在v含"时会产出"pa"ss",怎么切没有文档依据。loon_positional()对这种值返回 nil, 调用方整条丢弃。顺带修掉了既有的同类缺陷:anytls 的 Loon 位置参数 (7.4 之前就带引号)此前没有这层防护。
回归测试:tests/anytls_reality_test.lua 从 110 条扩到 143 条(新增 Loon 位置
参数与公钥引号断言、Surge 家族整体丢 vless 的断言、QX obfs= 分叉与「不带公钥
维持原样」的对照断言、QX 往返、Loon 端到端往返、加密方式映射、双引号边界);
tests/protocol_registry_test.lua 的 DROPPED 表补 vless 的 surge / surfboard /
surgemac 与 ssr 的 egern;tests/output_layer_fixes_test.lua 的 F6 改挂在 Loon 上
(surge 侧该节点已被整体丢弃,传输层已无从观察)。README / README.en.md 的
「SSR 只能输出到 Mihomo、Stash、Loon、Egern、Shadowrocket」一句里 Egern 已删
(它没有 SSR),并补了 VLESS 的同类说明。
反向验证:把 output_formats.lua stash 掉(保留全部新测试)后跑三个受影响的
文件,得到 20 条 FAIL,症状与预测逐条对应:
| 文件 | FAIL 数 | 症状 |
|---|---|---|
tests/anytls_reality_test.lua |
16 | QX 写的是 tls-host/tls-verification(无 obfs=);Loon 公钥无引号、凭据是具名;surge/surfboard/surgemac 仍输出 vless 节点;双引号未挡 |
tests/output_layer_fixes_test.lua |
2 | surge 仍保留 vless 节点与成员引用 |
tests/protocol_registry_test.lua |
2 | vless(surge/surfboard/surgemac) 与 ssr(egern) 的 want dropped got true |
git stash pop 后 0 条 FAIL,全套 57 个文件、0 失败。
第三轮修复记录(7.2)
7.2-A Egern YAML 生成器 —— 新增 root/usr/share/substore/output_egern.lua,
并删除 output_formats.to_egern(连同 M.generate 里的 format == "egern" 分支)。
output.lua 的分发改为 output_egern.generate,下载后缀 .conf → .yaml
(Content-Type 保持 text/plain; charset=utf-8,与同类的 clash / clashmeta /
stash 三个 YAML 格式一致 —— 单独给 egern 换成 application/yaml 只会让同一类内容
出现两种类型)。
结构(逐字段对照官方示例 egernapp.com/docs/configuration/example/ 与协议字段表
.../configuration/proxies/,无推测项):
proxies:是顶层键、值是列表,每项是单键映射,键名即小写协议名 (- shadowsocks:)—— 不是 Clash 的type:字段。字段名一律 snake_case:user_id/peer_public_key/preshared_key/skip_tls_verify/udp_relay/obfs_password/service_name/local_ipv4/dns_servers/udp_relay_mode。- vmess / vless 的传输层是
transport:子映射,键名是传输类型本身:tls/ws/wss/http1/http2/grpc。TLS 也是其中一种, 没有顶层tls:开关 —— 「明文 tcp」就是完全不写transport。这与 Clash 的network: ws+ws-opts是两套完全不同的写法。 - Reality 的嵌套位置按协议分叉:vmess / vless 在
transport.<类型>.reality里, trojan / anytls 是节点顶层的reality:对象。键名是public_key/short_id(既不是统一模型的public-key/short-id,也不是 Clash 的reality-opts)。 写错键名客户端不报错,只是 Reality 静默失效、退回普通 TLS。 policy_groups:同为顶层列表,select用policies:列表;空列表时兜底DIRECT。
协议清单的差异(EGERN_KEY,与 Surge 家族的 FAMILY_CAPS 是两回事):
Egern 有 VLESS 与 WireGuard,没有 SSR 与 Hysteria v1。后两者整条丢弃
(Hysteria v1 的 obfs 是普通字符串、没有 obfs_password,拿 v2 的键去顶会让客户端
按错误的协议去连)。FAMILY_CAPS 里的 egern 行随之删除。
顺带修正的两处既有行为(都由 protocol_registry_test 的 DROPPED 表锁定):
| 协议 | 此前 | 现在 | 原因 |
|---|---|---|---|
| wireguard | 丢弃(走 Surge 逗号行) | 保留 | Egern 的 WireGuard 有独立协议块,YAML 能完整表达 |
| hysteria (v1) | 保留(走 Surge 逗号行) | 丢弃 | Egern 的清单里只有 Hysteria2 |
复用而非复制:YAML 标量转义(引号触发集、控制字符、c-indicator)从
output_clash_meta.lua 导出为 M.esc_yaml 共用 —— 另抄一份必然漂移,而
「未加引号的 password: %foo 会让客户端拒绝整份配置」是同一个坑。
未做的一处(不猜):vmess 的 legacy 只在节点显式带 legacy 字段时输出。
官方文档没有给出「alterId > 0 ⇒ legacy: true」的对应关系,本仓库也没有
alterId 字段,因此不臆测映射,留待有依据时再补。
回归测试:新增 tests/output_egern_test.lua(63 条断言),覆盖顶层结构、
逐协议的字段名、transport 嵌套(ws/wss/grpc/http2)、Reality 的两种嵌套位置、
SSR 与 Hysteria v1 的整条丢弃、名字唯一性与重命名、YAML 转义、端口兜底、格式注册。
tests/protocol_registry_test.lua 的 DROPPED 表按上表改(删 wireguard.egern、
加 hysteria.egern);tests/anytls_reality_test.lua 的 egern 断言从「具名密码、
无 reality」改为 YAML 形态(password: p + reality: 子对象)。
反向验证:把 output.lua 与 output_formats.lua stash 掉(保留全部新测试)后
跑 tests/output_egern_test.lua,得到 53 条 FAIL(旧代码走 to_egern,输出的是
逗号行);git stash pop 后 0 条 FAIL,全套 58 个文件、0 失败。
第四轮修复记录(7.7)
7.7-A 后端错误串 msgid 化 —— 后端模块(root/usr/share/substore/*.lua)不再
出现任何中文字面量,全部改为语言中立的英文 msgid;翻译只发生在显示边界
(控制器 / 视图)。
硬约束:后端不能 require("luci.i18n")
substore-cron.sh 会在独立的 lua 进程里跑 core.sync,那里没有 LuCI 环境
(没有 luci.i18n、没有 translate())。所以后端不能自己翻译,只能返回 msgid。
这不是风格选择,是运行时的硬约束。
新增 root/usr/share/substore/msg.lua
有一类消息是「msgid 前缀 + 动态值」拼出来的,例如 "Invalid proxy config: " .. reason。
整体查表必然落空(值每次都不同),前缀那半句就永远翻译不了。msg.lua 用分隔符
把各段拼起来:
msg.compose("Target actually connects to a private/reserved address (", ip, ")")
msg.join("Unsupported proxy protocol: ", scheme) -- compose 的两段简写
msg.compose_list(names, " + ") -- 一串 msgid 用 sep 连接
msg.translate(s, translate) -- 显示边界按分隔符逐段查表
- 分隔符取
"\1"(SOH)。它不会出现在任何合法取值里(URL / 主机名 / User-Agent / 端口 / 协议名在http.lua里都校验过)。 - 能安全持久化:
util.json_encode把控制字符转义成\u0001,所以组合消息 存进meta.error再读回来仍然完好。 - 拆分是精确的:只按第一个分隔符切,剩下的递归处理 —— 嵌套(值本身又是 组合消息)天然成立。全程没有启发式匹配。
- 幂等:对已经翻译过的串(不含分隔符)整串查表落空,原样返回 —— 控制器里
已经
_()过的兜底串再次经过tmsg也不会被二次处理。
翻译点(两条例外路径)
| 路径 | 载体 | 在哪里翻译 |
|---|---|---|
| 失败后跳回列表 / 节点页 | ?err= |
控制器 back_to_list / back_to_nodes 里 tmsg(err) 后再 urlencode(先把控制字符翻掉再进 URL) |
| 更新失败的持久化原因 | meta.error |
视图渲染 it.error 时 msg.translate(it.error, luci.i18n.translate) |
output.generate 的返回值走 JSON 到视图(output.htm),同样在视图侧翻译。
顺带修掉的一个既有缺陷
控制器此前把已翻译的串写进持久化的 meta.error(core.save_meta(id, { error = _("…") }))
—— 中文管理员触发的失败原因会泄漏到英文管理员的界面上。现在存的是 msgid,
读它的会话按自己的语言翻译。
util.human_duration 的写法
数字与单位拆成两段(msg.compose(3, " days")),单位 msgid 带前导空格 ——
英文渲染成 3 days,中文译文把空格去掉,渲染成 3天。这条由调用它来断言
(见下),而不是在源码里认字面量。
保留的中文字面量(唯一一处)
node.lua:377 的 "[^,%s,]+" —— 这是正则字符类,全角逗号是模式的一部分,
翻译它会直接破坏「香港,日本」这类全角分隔写法的关键词拆分。测试里按字面量内容
(不是行号)列为显式例外,并额外断言「例外必须仍存在于源码中」,防止例外表腐烂成
「什么都放行」。
顺带修正的两条用户可见文案
扫描时发现两条已经会到达用户、但仍是内部诊断口吻的英文串(它们此前漏在 翻译表之外,中文界面下会显示英文):
| 位置 | 此前 | 现在 |
|---|---|---|
parser.lua:805 |
"bad wireguard conf: no [Peer]" |
"A WireGuard .conf file has no [Peer] section" |
parser.lua:904 |
"bad wireguard conf: no usable [Peer] endpoint" |
"A WireGuard .conf file has no [Peer] with a usable Endpoint" |
(parse_wireguard_conf 的返回值由 M.parse 里 if not nodes then return nil, err end
原样上抛,与那些被丢弃的逐行诊断不同。其余的 "bad ss" / "no scheme" 之类只用于
决定「这一行要不要丢」,原因串被 parse_lines 丢掉,不是用户可见文案。)
同理,output.lua / output_formats.lua 的 "unsupported format: " .. tostring(format)
改为 msg.join("Unsupported output format: ", tostring(format)) 并补了译文。
翻译表
po/zh_Hans/substore.po 新增 81 条(113 处字面量里有重复,去重后加上组合消息的
各个片段、FORMAT_LABELS 的 5 个显示名、以及上面两条改写后的文案)。其中
"JSON" / "Clash YAML" / "WireGuard .conf" 三条是恒等译文 —— 它们是格式
专名,中文界面下也原样显示;写成显式条目是为了让「每个 msgid 都有译文」这条不变式
保持机械可检,而不是留一个说不清的例外。
回归测试
新增 tests/backend_i18n_test.lua(15 条断言),三组:
- A 无 CJK:去注释后扫全部后端源码的字面量,不得含 CJK(唯一例外见上)。
- B 译文齐全:只认三种语法位置 ——
return语句、error =字段、msg.compose/join/compose_list的参数(外加FORMAT_LABELS的表值)。这三处 穷举了后端的出口,不靠猜。 - B2 / B3:
util.human_duration用调用它来断言(比认字面量更接近事实); 控制器里裸写、会被持久化的error = "…"单独查(其余控制器文案在源头就_()掉了,由view_i18n_test的 B2 覆盖)。
另外两条反腐烂断言:NOT_MESSAGES 排除表里每一项都必须仍能在源码里找到;
po 的后端段里不能有死条目(每条都得真的被后端用到)。扫描面还有一条
非空性断言(checked >= 70,当前实际约 80),防止正则写错时静默变成假绿。
NOT_MESSAGES 表逐条列举而不是按模式匹配:同处那三种语法位置、但不是文案的
字面量共 30 条 —— parser.lua 的 17 条逐行解析诊断(调用方丢弃原因串)、
http.lua / probe.lua 的 5 条 shell 命令行片段、output_formats.lua /
output_clash_meta.lua 的 8 条 YAML/INI 模板片段。逐条列的理由是:新增一条就得在
表里做一次有意识的判断,模式匹配会让新写的文案悄悄漏过去。
同步修改的既有测试(9 个):core_userinfo_test / data_integrity_test /
dns_fallback_test / list_lock_test / network_security_test / p1_fixes_test /
p2_batch7_test / parser_mixed_format_test / wireguard_conf_test —— 它们此前断言
的是旧的中文字面量,改为断言 msgid / 组合消息的形态。其中 p2_batch7_test 的
改名规则断言改为对渲染后的串断言(msg.translate(s, function(k) return k end)),
因为组合消息在 "line " 与数字之间插了分隔符 —— 断言用户真正看到的东西才是对的。
反向验证:在 probe.lua 末尾临时加一条 return nil, "A brand new untranslated message" 后跑 tests/backend_i18n_test.lua,得到 1 条 FAIL(no po entry: … A brand new untranslated message),确认扫描面不是空转;还原后 15 条全 PASS。
全套 59 个测试文件、0 失败。
第五轮修复记录(7.9)
7.9 (a)(b)(c)(d) 按官方文档对齐 Loon / Surge 家族的写法,(e) 保持保守
(该保守决策已于第八轮部分修订,见「第八轮修复记录」)。
全部改动集中在 root/usr/share/substore/output_formats.lua 的 M.surge_line
(生成端)与 root/usr/share/substore/parser_surge.lua 的 parse_surge_line
(解析端)—— 两端必须成对,否则导出的配置再导入回来会静默丢字段。
(a) TLS 开关:Loon 用 over-tls,Trojan 例外
local is_loon = (flavor == "loon")
local tls_flag = is_loon and "over-tls=true" or "tls=true"
vmess / vless 的 if tls then … end 改用 tls_flag。Trojan 反过来:Loon 文档
的明文与 Reality 两条示例都没有任何 TLS 键(Trojan 本身建立在 TLS 之上),
所以 Loon 的 trojan 行整个不写 TLS 参数,Surge 家族的 tls=true 保持不动。
(b) UDP 开关:Loon 用 udp
if n.udp then e[#e + 1] = is_loon and "udp=true" or "udp-relay=true" end
(c) Surge 家族补 encrypt-method
新增 SURGE_VMESS_CIPHER(aes-128-gcm → aes-128-gcm、
chacha20-poly1305 → chacha20-ietf-poly1305),在非 Loon 的 vmess 分支写出。
映射不到的取值(zero / none / auto / aes-128-cfb)不写该参数、节点保留:
Surge 的清单只有那两个取值,写一个客户端读不懂的比不写更糟(写与不写都是
aes-128-gcm 默认值,语义等价)。Loon 侧加密方式本就在位置参数上,不受影响。
(d) Loon 的 ss / ssr / hysteria2 改位置参数
Shadowsocks,服务器,端口,加密方式,"密码"
ShadowsocksR,服务器,端口,加密方式,"密码",protocol=…,obfs=…
Hysteria2,服务器,端口,"密码"
密码走 loon_positional(加双引号,值里含双引号则整条丢弃);ssr 的
protocol= / obfs= / obfs-param= / protocol-param= 仍是具名参数。
解析端同步(并修掉一个既有缺陷)
parse_surge_line 的字段提取补上位置参数:
| 协议 | 生成端(Loon) | 解析端 |
|---|---|---|
| shadowsocks | 加密方式,"密码" |
kv.* or pos[1] / kv.password or pos[2] |
| ssr | 同上 + 具名插件参数 | 新增分支(此前 ssr 没有任何取字段的分支) |
| hysteria2 | "密码" |
kv.password or pos[1] |
SSR 此前根本没有取字段的分支 —— 不论哪种写法导入,加密方式、密码、协议插件、
混淆方式全是 nil,节点带着空凭据进入统一模型,导出时再被静默丢掉。这是本轮顺带
修掉的既有缺陷,不是 (d) 引入的。obfs-param / protocol-param 两种拼写都写一份
(PROTO_FIELDS / DEFAULTS 用带连字符的规范写法,各输出模块读的是下划线别名,
与 parser.lua 同时写 method / cipher 同一理由)。
顺手更正的陈旧注释
surge_line 里 anytls 分支上方原写着「这是本文件里唯一按 flavor 分叉的协议:
trojan / vmess / vless 一律用具名参数」—— 7.4-A 之后就不成立了,已改写。
测试
tests/ssr_test.lua:loon encrypt-method一条改为断言位置参数写法,并新增 9 条 Loon 输出 → 导入回环断言。tests/output_formats_test.lua:新增 7.9 专节 24 条((a) 6 / (b) 3 / (c) 4 / (d) 4 / (e) 1 / 回环 6)。(e) 那 1 条已于第八轮随修订改写,见「第八轮修复记录」。- 反向验证(逐条把代码改回旧行为,确认测试变红):
tls_flag写死tls=true→ 3 条 FAIL;UDP 写死udp-relay→ 2 条 FAIL;去掉encrypt-method→ 2 条 FAIL; Loon 的 ss / hysteria2 改回具名 → 2 条 FAIL;解析端 ssr 分支停用 → 5 条 FAIL; 解析端 hysteria2 / ss 位置参数去掉 → 各 1 条 FAIL。还原后全部 PASS。
全套 59 个测试文件、0 失败。
本轮新发现(见上文「第五轮新发现」)
(g) Loon 没有 Hysteria v1;(h) skip-cert-verify=1 的取值无文档依据;
(i) 协议名大小写。三条当时只记录、不动代码;(g)(h) 已在第六轮实施,
(i) 决定保持现状。
第六轮修复记录(7.9 的 g / h)
第五轮新发现里经决策要实施的两条。(i) 决定保持现状,不动代码。
(g) Loon 丢弃 Hysteria v1
loon = { vless = true, ssr = true, hysteria = false },
判据:Loon 的节点类型清单里只有 Hysteria2(nsloon.app/docs/Node/)。
hysteria 2 是各家通用的,被丢的只有上一代 v1 —— 两者共用同一个输出分支
(elseif proto == "hysteria2" or proto == "hysteria"),最容易误伤。
顺带把过滤逻辑改成数据驱动。此前是逐协议写死的:
elseif p == "ssr" and not caps.ssr then -- 旧
elseif p == "vless" and not caps.vless then -- 旧
elseif caps[p] == false then -- 新
每加一个维度就要再加一条分支,漏掉的那条默认是「保留」 —— 这正是本条缺陷的
成因(hysteria 从没进过这张表)。改成查表后,FAMILY_CAPS 里标一个 false
即可,不会再有「忘了加分支」这种漏法。caps[p] 为 nil(表没表态)一律保留。
(h) skip-cert-verify 改写 true
if n["skip-cert-verify"] then e[#e + 1] = "skip-cert-verify=true" end
判据:Loon 文档的示例就是 skip-cert-verify=false,直接证明 false 是合法
字面量、true 同理;而 1 在任何一家文档里都找不到(Surge 手册的 TLS 页只写
「Optional, boolean, default: false」)。解析端两种都认,回环不受影响。
断言面审计:全仓库只有 output_formats.lua 一处写这个串;既有测试里没有
任何一条断言过生成端的 skip-cert-verify=1 —— anytls_reality_test.lua:151 那处
是把 =1 当输入喂给解析器,改写后仍然有效。
(i) 协议名大小写:保持现状
无证据显示小写会失败(Loon 定位是兼容 Surge 配置,Surge 手册的语法行就是小写), 改动面却覆盖全部协议。不改。
测试
tests/protocol_registry_test.lua:hysteria的丢弃表加loon一条。tests/output_formats_test.lua:新增 (g) 5 条 / (h) 4 条断言,含回环。 其中一条如实钉住 Surge 家族当前仍输出 hysteria v1(发现 (j),待决策), 实施 (j) 时要一并翻转。- 反向验证:
FAMILY_CAPS.loon.hysteria改回不设 /skip-cert-verify改回=1, 确认测试变红。 - 全套 59 个测试文件、0 失败。
本轮新发现(见上文「第六轮新发现」)
(j) Surge 家族同样没有 Hysteria v1(manual.nssurge.com/policies/hysteria.html
是 404、hysteria2.html 存在)。本轮没有顺手改 —— (g) 的决策只点名了 Loon。
→ 已于第七轮实施。
第七轮修复记录(7.9 的 j)
第六轮新发现 (j):Surge 家族的手册协议清单里写的也是 "Hysteria 2",
manual.nssurge.com/policies/hysteria.html 是 404 而同目录的 hysteria2.html
存在 —— 与 Loon 同一情形。本轮按决策把丢弃延伸到 Surge / SurgeMac。
改动
root/usr/share/substore/output_formats.luaFAMILY_CAPS.surge/FAMILY_CAPS.surgemac各加hysteria = false。 Surfboard 不加 —— 文档 404、证据不足(决策是「Surge + SurgeMac 一起丢」)。FAMILY_CAPS上方的依据注释同步更新:Surge / SurgeMac 的清单补上「也没有 Hysteria v1」及其探针证据;Surfboard 一段写明「未获证据,故保留」。surge_config的过滤注释补上hysteria一行,说明它同样按FAMILY_CAPS分客户端、Surfboard 因本表沉默而保留。- 过滤逻辑本身不动:第六轮已改成数据驱动(
elseif caps[p] == false), 本轮只是往表里加键 —— 这正是第六轮那次重构要买的东西。
测试
tests/protocol_registry_test.lua:hysteria的丢弃表加surge/surgemac两条,并注明 Surfboard 不在其中。tests/output_formats_test.lua:(j) 新增 5 条断言 —— surge 丢 v1 / 留 v2 / 组不含 v1、surgemac 丢 v1、surfboard 留 v1(无证据丢弃);第六轮那条surge still keeps hysteria v1 (finding j pending)占位断言已删除(被surge drops hysteria v1取代)。- 反向验证:把
hysteria = false从 surge / surgemac 两行撤掉 → 4 条 FAIL (output_formats 3 条 + registry 1 条),而surfboard keeps hysteria v1那条始终绿(本轮没碰 Surfboard,它本就不该受影响)→ 说明断言确实指向 本轮改动,且没有误伤范围外的行为。还原后全绿。 - 全套 59 个测试文件、0 失败。
本轮新发现
无。这是 7.9 决策链的最后一条;实施完 (j) 后 7.9 的 (a)–(e)、(g)–(j) 全部结清 ((f) 复核确认本来就对,(i) 决定保持现状)。
(e) 的例外:(e) 当时按「保持保守」结清,但第八轮复核发现该保守决策建立在一个 错误的注释上(详见「第八轮修复记录」),于是对位置参数那一半做了修订 —— 具名参数仍保持保守。这是 7.9 唯一一处「结清后又修订」的条目。
第八轮修复记录(7.9 (e) 的修订)
7.9 (e) 的保守决策被部分推翻:Loon 引号包裹的位置参数放行含英文逗号的值。
起因
第五轮把 (e) 判为「保持保守:位置参数或具名值里含英文逗号就整条丢弃」,
并在 surge_line 里写下一段理由,其中一句是错的:
Loon 的那对引号只是标记,值里的逗号照样是分隔符。
Loon 官方文档(nsloon.app/docs/Node/)原文是「参数值中含有英文逗号时,请使用
双引号包裹」—— 这句话只有在客户端解析是引号感知的前提下才成立,也就是说那对
双引号确实能保住值里的逗号。原注释把「引号」当成了纯粹的装饰,据此推出的
「引号救不了逗号」不成立,整条丢弃的代价(极少见的「密码里带逗号」的节点在 Loon
上被丢弃)也就失去了依据。
精确范围(用户确认)
只改 Loon 的引号包裹位置参数这一种情形,其余一律不动:
| 情形 | 第五轮 | 第八轮 |
|---|---|---|
Loon 位置参数,值被 "…" 完整包裹 |
丢弃 | 放行 |
| Loon 位置参数,裸值(无引号,如加密方式) | 丢弃 | 丢弃(无引号语义) |
具名参数(Surge 家族 + Loon 的 sni= / ws-path= 等) |
丢弃 | 丢弃(Surge 引号语义未获文档证据,且解析端不对具名值 unquote,加引号会断回环) |
| Surfboard 的裸位置参数(anytls) | 丢弃 | 丢弃(无引号语义) |
判据不是 is_loon 而是「这个值是不是引号包裹的」(v:match('^".*"$')):
loon_positional 只在 Loon 分支被调用,Surfboard 的 anytls 走裸位置参数,
两者由这个判据自动分开,不必再分叉一次。
安全性依据
放行不会让生成端产出解析端读不动的行,因为 loon_positional 有两条保证:
- 它无条件把 Loon 的位置凭据包成
"…",所以引号是 Loon 语法的一部分; - 值里含
"时它直接返回nil(整条丢弃,走不到这一行)—— 于是引号包裹的值 内部不可能出现落单引号,parser_surge.split_fields的「奇数引号回退」分支 不会被本行的输出触发。
改动
root/usr/share/substore/output_formats.lua:surge_line的位置参数检查if v:find(",", 1, true) then return nil end改为if v:find(",", 1, true) and not v:match('^".*"$') then return nil end; 上方注释整段重写(说明引号语义、判据选择与安全性依据)。- 具名参数检查(上一段
for _, kv in ipairs(e))原样不动。
测试
tests/output_formats_test.lua:第五轮那条loon comma password drops node改为loon comma password kept (quoted positional),并新增回环断言loon rt comma password/loon rt comma method;同时保留surge comma password drops node作为反向护栏。tests/anytls_reality_test.lua:新增surge_line comma in quoted positional password kept、loon comma password round-trips、surge_line comma in loon vless uuid kept;保留surge_line comma in named password -> nil与surge_line comma in bare positional password -> nil两条护栏。- 断言一律先
local line = fmts.surge_line(...)再判type(line) == "string": 直接在nil上调:find会抛错、把同文件后续断言全吞掉,反向验证就只能看到 「崩了」而看不到是哪条行为变了。 - 反向验证:把谓词改回
if v:find(",", 1, true) then(撤掉放行)→ 6 条 FAIL 全部指向本轮改动(output_formats 3 条 + anytls 3 条),4 条护栏断言 (surge comma password drops node、具名密码、裸位置密码、具名 uuid)始终绿 → 说明断言确实指向改动,且没有误伤范围外的行为。还原后全绿。 - 全套 59 个测试文件、0 失败。
附:P2 修复范围(历史范围定义,已结清)
本节不是待办清单,是 P2 开工时写下的范围定义(
2d29250,即 P2 批次一 那次提交),原文用的是「例如 … 等」,本身即带示例性质。P2 已由批次一~批次七 全部实施完毕(见文件开头的进度表)。保留本节是为了说明当时划定的边界; 若日后有人照着它去「补修 P2」,那是在修一份已经修完的清单。
P2 为审计表中其余中危 / 低危项中性质明确、无需另行决策的缺陷,例如 M6 / M9 / M13 / M14 / M16 / M17 / M18 / M19 / M23 / M24 / M25 / M26 / M27 / M30 与 L1 / L3~L6 / L8~L26 等。逐项修复并补回归测试,完成后在 CHANGELOG 中记录。 本文件所列各项不在 P2 范围内。
逐条落点(38 项,35 项有据)
| 批次 | 版本 | 覆盖的 ID |
|---|---|---|
| 批次一 | [2.6.3-r1] |
M13 / M14 / L13 |
| 批次二 | [2.6.4-r1] |
M9 / L16 / L17 / L18 / L20 |
| 批次三 | [2.6.5-r1] |
M19 / M23 / M24 / M25 / M27 / L21 / L22 |
| 批次四 | [2.6.6-r1] |
M16 / M17 / M18 / M30 |
| 批次五 | [2.6.7-r1] |
L3 / L4 |
| 批次六 | [2.6.8-r1] |
L10 / L11 / L12 / L14 |
| 批次七 | [2.6.9-r1] |
L1 / L5 / L6 / L8 / L9 / L23 / L24 / L25 / L26 |
| P0 批次四 | [2.6.0-r5] |
M26(P2 开工前即已修复,此处仅备查) |
每一项的内容见对应版本的 CHANGELOG;本表只做 ID → 落点的索引,不重复描述。
三项查无实据:M6 / L15 / L19
这三项落在上面的范围区间内,但仓库里没有任何记录 —— 既无 CHANGELOG 落点,
也无缺陷描述。原因见文件开头的「编写纪律」:原始 70 项审计表(高危 14 /
中危 30 / 低危 26)从未落盘。其中 M6 只在上面那一行范围定义里被提及过一次;
L15 / L19 连提及都没有,它们是被 L8~L26 这个区间顺带覆盖进去的。
处置:不追补。 编号本身不指向任何模块或文件,无从复核当时指的是什么。按 「不猜」原则,本节不为它们补写描述,也不假装它们已被评估过 —— 只如实记下 「无据可查」这一事实。若日后拿到原始审计表,再按当时的口径单独决策。
两个区间外的编号,一并备查:L2 是 converter.lua / node_converter.lua
死代码(见上文 1.4),已在 P4 删除([2.6.11-r1]),本就不在 P2 区间内;
L7 同样查无记录,且不在本区间内。