🎉 Sync 2026-09-27 21:03:26

This commit is contained in:
github-actions[bot] committed 2026-09-27 21:03:26 +08:00
1 parent f4559bbed1
commit 065763f7fd
673 files changed
+261285 -34160

No files matched your search

+195
View File
@@ -0,0 +1,195 @@
# Changelog — luci-app-substore
All notable changes to this project will be documented in this file.
## [2.1.3-r6] - 节点分组与单节点编辑
- 表单导入节点行新增「分组」输入框(`data-k="group"`,parse_local 透传入模型)
- 节点页「关键词:」前新增「分组:」下拉筛选(选项为当前订阅节点的去重分组,精确匹配走 node.filter);排序新增按分组
- 节点页「名称」后新增「分组」列:单元格内嵌输入框,onchange 经 XHR 提交 node_set_group 无刷新保存(成功绿闪/失败红闪提示)
- 节点页行尾新增「操作」列:编辑(新页面 node_edit.htm,复用协议动态字段,可改名称/分组/服务器/端口/协议全字段)与删除(confirm 确认,返回保留筛选参数)
- 单节点标识:渲染前标注原始数组下标 __idx(filter 保留表引用、sort 原地排序,下标穿透筛选排序)
- core.lua 新增 merge_form_node:表单字段整体替换(可清空),raw/tags 等非表单字段保留;删除/编辑后刷新引用该订阅的组合
- 动态字段 JS 抽取为 /luci-static/resources/substore/nodeform.js,local_form.htm 与 node_edit.htm 共用(翻译字典留模板内服务端渲染)
- 控制器新增 node_edit / node_save / node_delete / node_set_group 四路由
- 已知语义:单节点编辑/删除/分组在订阅下次「更新」或本地订阅重新保存后被覆盖(用户已确认接受)
- po/zh-cn 新增 分组/编辑节点/删除确认 等 6 条;tests/core_merge_node_test.lua 新增(17 断言)
## [2.1.3-r5] - 本地订阅表单全面接入系统语言
- local_form.htm 规则区硬编码中文(启用规则/关键词包含/关键词排除/去重/提示语)改为 `<%:...%>` 可翻译字符串,随 OpenWrt 系统语言自动切换中英文
- po/zh-cn 新增 Enable rules / Keyword include / Keyword exclude / Dedup 等 5 条
- 至此 local_form.htm 页面无任何硬编码中文(剩余中文均为代码注释)
- 说明:旧 form.htm(订阅链接表单)存在同样的硬编码中文存量问题,未在本次改动范围内
## [2.1.3-r4] - 表单导入枚举字段下拉化 + hysteria2 混淆闭环
- 表单导入枚举字段改为下拉框(选项以模型/输出模块实际支持的值为准,对齐 PassWall 式交互)
- 传送方式 net:tcp/ws/h2/grpc;伪装类型 headerType:none/http(vmess/vless/trojan/shadowsocks 新增该字段)
- vmess 加密方式 cipher:auto/aes-128-gcm/chacha20-poly1305/none/zero;新增 TLS 开关(修复表单无法生成 TLS vmess 节点的缺口)
- vless 新增 flow(默认/xtls-rprx-vision);security:none/tls/reality
- shadowsocks 加密方式 method:常用 11 种枚举
- hysteria2 新增混淆类型 obfs(无/salamander)+ 混淆密码 obfs-password
- 下拉选项按协议区分(hysteria2.obfs),不影响 ssr 的 obfs 自由文本
- hysteria2 混淆导入导出闭环:output_uri 补 obfs/obfs-password 参数输出;output_clash_meta 补 obfs/obfs-password 行;output_singbox 补 obfs 对象;parser 的 hy2 URI 解析回读 obfs 参数
- po/zh-cn 新增 伪装类型 / 混淆密码 翻译
- tests/parser_local_form_test.lua 扩至 39 断言:hy2 混淆透传/URI 回环/双输出、vmess headerType/tls 透传
## [2.1.3-r3] - 表单导入字段中文化并扩充协议字段
- 表单导入动态字段标签支持简体中文(server/port/password/cipher/net/path/sni 等,po/zh-cn 新增 13 条)
- 扩充各协议字段集合,覆盖 Clash 风格节点常见字段:vmess/vless/trojan 新增 path、host、udp、skip-cert-verify;vmess 新增 alterId、cipher;ssr/shadowsocks/tuic/wireguard 新增 udp
- udp / skip-cert-verify 改为下拉框(默认/true/false),不再手填文本
- parser.lua `parse_local` 表单模式改为全字段透传 + 类型修正(port/alterId 转数字,udp/skip-cert-verify 字符串转布尔,network→net、method↔cipher、obfs-param/protocol-param 别名同步),不再因白名单丢字段
## [2.1.3-r2] - 修复本地订阅表单导入跳回列表
- 修复「添加本地订阅」页选择「表单导入」直接提交表单并跳回订阅列表的问题
- local_form.htm:模式切换下拉框原为 `onchange="this.form.submit()"`,改为纯 JS 切换显示,不再提交
- 移除重复的 hidden `local_mode` 字段;文本/表单两个 `content` 字段通过 disabled 互斥,保证只提交一个
- 提交前(onsubmit)表单模式将节点序列化为 JSON 写入隐藏 content 字段
- 编辑页初始内容由 `util.json_encode` 注入 JS(不再 pcdata 转义,避免破坏 JSON.parse)
## [2.1.3] - 新增本地订阅功能
- 新增本地订阅:支持文本导入与表单导入双模式,本地订阅不走网络更新,Update 按钮禁用
- 首页按钮调整:`添加订阅` → `添加订阅链接`,新增 `添加本地订阅`
- 数据模型扩展:`local`、`raw_content`、`local_mode` 字段,`core.add_local` / `parser.parse_local`
- 控制器新增 `localform` / `local_create` / `local_save` 路由
- 视图新增 `local_form.htm`,表单导入支持 vmess/vless/trojan/shadowsocks/ssr/hysteria2/tuic/wireguard 动态字段
## [2.1.2] - 完善协议列表
- 补齐节点列表和规则配置中缺失的协议选项
- nodes.htm:补齐 ssr、hysteria、wireguard、socks 四个协议
- substore.lua:补齐 ssr、hysteria、wireguard、socks 四个协议
## [2.1.1] - 修复延迟显示 Bug
- 修复「节点」页面延迟列显示异常问题
- 修正 innerHTML 与 textContent 的差异,确保失败时正确显示红色 "Fail" 文本
- 之前版本中由于使用 textContent,HTML 标签被显示为纯文本
## [2.1.0] - 网络探测优化
- 优化「节点」页面的网络探测(Ping/TCPing/URL Test)显示
- 在节点列表表格中新增「延迟」列,直接显示每个节点的测速结果
- 移除独立的探测结果弹窗,结果直接展示在表格内,查看更加直观
- 使用 data 属性进行节点匹配,提高稳定性
## [2.0.0] - 组合订阅
- 新增「组合订阅」(选择性合并多个订阅 + 组合订阅链接)
- core.lua:新增 add_combo / save_combo / combo_nodes / combo_refresh / refresh_combos / is_combo;
组合订阅物化为自身节点文件,无 URL、无 cron/代理,可叠加关键词包含/排除与去重规则;
sync 遇组合走重算而非下载,源订阅更新后自动 refresh_combos 重算依赖它的组合
- controller:新增 combo / combo_save 路由与 action_combo_save(复用 read_rules_fields)
- view:新增 combo.htm(名称 + 来源复选框 + 规则显隐);subscriptions.htm 增「添加组合订阅」
按钮、组合行徽标与来源显示、编辑路由区分
- Makefile:安装 combo.htm;版本号 1.0.0 → 2.0.0(2.0.0-r1)
- i18n:po/zh-cn 新增 添加/编辑组合订阅、组合、来源订阅
- tests/core_combo_test.lua 新增(23 断言)
## [1.0.0] - 正式版
- 修复 Clash 订阅(机场常见「流式 JSON 节点」写法)解析为 0 节点的问题
- 部分机场生成的 Clash/Mihomo 配置把每个代理节点写成单行流式 JSON:`- {"name":"…","type":"vmess","server":"…","port":443,…}`(含嵌套 `ws-opts`),而非缩进块风格;原解析器只认块风格,导致 `name`/`server`/`port` 全部读空、节点被丢弃
- parser_clash_yaml.lua `read_list`:识别 `- {…}` 流式 JSON 对象并 `util.json_decode` 解析
- parser_clash_yaml.lua `map_clash_node`:把 Clash 的 `ws-opts.path` / `ws-opts.headers.Host` 映射到统一模型的 `path` / `host`(此前 ws 节点丢失 path)
- tests/parser_clash_yaml_test.lua 新增流式 JSON 节点用例(vmess ws + ssr,含 path/host 映射,+14 断言)
- 修复 Shadowrocket 订阅(base64url / BOM)无法解析、内部 vmess 节点读不出的问题
- parser.lua `detect`:base64 检测接受 URL-safe base64url(`-` `_` 无 padding)与 UTF-8 BOM 前缀;
对「纯字母数字且长度非 4 倍数」的去 padding base64url,尝试解码并校验是否含 vmess/vless/trojan/ss/ssr 节点再判定
- parser.lua `parse` / `parse_vmess`:改用 `util.base64_url_decode`(兼容标准 base64 与 base64url)
- tests/run_tests.lua 增补 base64url / BOM 订阅检测与解析用例(+4 断言)
- 补齐协议能力矩阵的四项缺口(hysteria2 / tuic / wireguard 全链路导入导出 + JSON 配置导入放开 + Surge 家族输出)
- parser.lua:SUPPORTED 增加 hysteria2 / tuic / wireguard;新增 parse_hysteria2 / parse_tuic /
parse_wireguard(wireguard 采用本项目自定义 scheme `wireguard://base64(json)#name`,因 wireguard 无统一 URI 标准)
- parser_json_config.lua:proto_map / SUPPORTED 从 4 协议扩到 11(补 hysteria/hysteria2/tuic/wireguard/
socks/http/ssr);sing-box 解析补 hysteria2/tuic/wireguard/socks/http 分支,V2Ray 解析补 socks/http 分支,
Clash 解析补 hysteria2/tuic/wireguard/socks/http/ssr 分支(ssr 读 cipher/protocol/obfs/obfs-param/protocol-param)
- output_uri.lua:新增 wireguard 输出(`wireguard://base64(json)#name`),与 parser 回环一致
- output_formats.lua:Surge 家族新增 tuic 行(username=uuid/password/sni/alpn);surge_config 统一丢弃
wireguard(Surge 需专用多段 [WireGuard] 配置,单行无法表达),ssr 仅 Loon/Egern 保留
- output_singbox.lua:wireguard 输出读取 kebab-case 字段(private-key/peer-public-key/preshared-key,
snake 回退),导入后经此输出字段不再丢失
- node.lua:PROTOS 已含 hysteria2/tuic/wireguard,无改动
- tests/protocol_support_test.lua 新增(58 断言):hy2/tuic/wireguard URI 导入、base64 订阅、
sing-box/Clash JSON 导入、Surge tuic 输出 + wireguard 丢弃、sing-box wireguard kebab 输出
- 新增 SSR(ShadowsocksR)订阅源支持
- parser.lua:SUPPORTED 增加 ssr;新增 parse_ssr 解析 ssr:// 分享链接(外层 base64、
密码/参数 base64url 兼容标准 base64,remarks/obfsparam/protoparam/group)
- util.lua:新增 base64_url_encode / base64_url_decode(RFC 4648 base64url)
- node.lua:PROTOS 注册 ssr
- output_clash_meta.lua:ssr 输出完整字段(cipher/password/protocol/obfs/obfs-param/protocol-param)
- output_uri.lua:新增 to_ssr_uri 生成 ssr:// 分享链接(Shadowrocket / V2Ray URI 输出)
- output_formats.lua:Loon / Egern 输出 SSR 行;Surge/Surfboard/SurgeMac 跳过 ssr(不支持)
- output_singbox.lua / output_v2ray.lua:跳过 ssr(不支持 SSR,丢弃而非输出非法配置)
- SSR 仅能原样输出到支持它的客户端,不能与 vmess/vless 等其它协议互转(协议不兼容)
- view/form.htm:「订阅 URL」输入框宽度与「代理地址」对齐(70% → 60%)
- tests/ssr_test.lua 新增(41 断言)
- 编辑订阅页新增「订阅代理」:开启后通过代理地址下载订阅(解决国内直连失败)
- http.lua:新增 parse_proxy(http/https/socks4/socks5/socks5h,含 user:pass@,防注入);download/curl/wget 支持代理
- core.lua:订阅元数据新增 proxy_enable/proxy;sync() 开启且地址有效时经代理下载
- controller:create/save 读取并持久化 proxy_enable/proxy
- view/form.htm:订阅 URL 下方新增「订阅代理」复选框,开启时显示「代理地址」输入框(默认关闭)
- tests/http_proxy_test.lua 新增
- 编辑订阅页展示剩余流量 / 剩余时长(仅编辑页,首页不显示)
- http.lua:下载时捕获响应头,download() 返回值改为 body, headers, err;新增 read_headers
- core.lua:新增 parse_userinfo;sync() 解析 subscription-userinfo 头并持久化 upload/download/total/expire
- util.lua:新增 human_bytes / human_duration
- view/form.htm:统计行新增「剩余流量 / 剩余时长」
- po/zh-cn:新增 Remaining traffic / Remaining time 翻译
- tests/core_userinfo_test.lua 新增
- 简体中文 i18n(运行时语言 zh-cn 时自动显示中文,英文时保持英文;24.10 / 25.12 实机均已验证)
- po/zh-cn/substore.po 新增(菜单/按钮/列头/探测结果等全部 UI 字符串)
- Makefile: install 步骤用 po2lmo 编译为 substore.zh-cn.lmo 并打包到 /usr/lib/lua/luci/i18n/
- Nodes 页新增节点网络探测(Ping / TCPing / URL 测试)
- probe.lua 新增:Ping(ICMP 解析 time=)、TCPing(nc -z 测连接耗时)、URL 测试(curl time_total / wget uptime 差值);并行探测,总耗时≈单节点超时;主机名/IP 白名单校验防命令注入
- controller: action_probe 端点(POST id/mode/proto/keyword,返回 JSON),按当前 proto/keyword 过滤后逐个探测
- view/nodes.htm: 筛选按钮后新增三按钮 + 结果表格(延迟/失败 + 成功数与平均延迟);Desc 复选框与下拉框间距、复选框与文字间距修正
- tests/probe_test.lua 新增(18 断言)
- 规则编辑页简化与对齐(form.htm)
- 「定时更新时间」与「关键词包含/排除」改为与其它行一致的 cbi-section-descr + min-width:10em 标签,左对齐;cron_time_row 由 display:flex 改为 block + inline-flex
- 移除「协议过滤」「重命名规则」字段;底部新增多关键词备注
- node.lua: 关键词包含/排除支持逗号(含中文逗号)/空白分隔的多关键词,命中任一即保留/去除
- tests/core_rules_test.lua 增补多关键词用例(14 断言)
- 规则下沉到订阅(per-subscription),更新订阅时直接生效;移除全局「设置」页
- core.lua: 订阅元数据新增 rules_enable / proto_filter / keyword_include / keyword_exclude / dedup / rename_map;新增纯函数 apply_rules();sync() 解析后按订阅规则过滤/去重/重命名再落盘;移除 load_rules()(不再依赖 luci.model.uci)
- controller: read_rules_fields();create/save 读取并持久化规则;移除 settings/settings_save 路由与 action_settings_save
- view/form.htm: 新增「启用规则」复选框 + 规则字段(协议过滤/关键词包含/排除/去重/重命名),随复选框显隐
- 删除 view/settings.htm、menu.d 的 Settings 条目、UCI config rules 'default';uci-defaults 清理旧 substore.default
- tests/core_rules_test.lua 新增(11 断言)
- Stage 4: 定时更新、规则、错误处理、安全加固、多版本兼容
- substore-cron.sh: 重写为独立 cron 可运行(显式 package.path、自动探测 lua5.1/lua、pcall 保护、输出走 logger)
- node.lua: 重命名规则统一三种形式——精确 `旧=新`、正则 `pattern -> replacement`、模板 `{server}_{port}_{proto}`(parse_rename_rules / rename_with_rules / apply_rules)
- view/settings.htm: 重命名规则 hint、协议过滤/关键词/去重规则配置
- controller: action_update 用 pcall 兜底,解析/写入异常不 500,错误落库并在列表页状态列展示
- controller: action_download 文件名白名单化,防 HTTP 头注入
- Makefile: LUCI_DEPENDS 显式 +luci-lua-runtime +luci-compat(23.05/24.10 兼容)
- docs/UCODE_MIGRATION.md: `.htm` → `.ut`(ucode)迁移评估与对照(未执行,需 25.12 构建环境验证)
- tests: node_rename_test.lua 增补精确匹配重命名用例(27 断言)
- Stage 3: 订阅转换 + 订阅链接(核心功能)
- output.lua: 统一分发全部 13 种目标格式(FORMAT_ALIASES 别名映射)
- output_uri.lua: vmess/vless/trojan/ss/hysteria2/tuic/socks 分享链接、Shadowrocket(base64)/V2Ray URI
- output_singbox.lua: sing-box JSON outbounds
- output_v2ray.lua: V2Ray/Xray JSON outbounds
- output_formats.lua: Surge/Surfboard/SurgeMac/Loon/Egern/QX/Stash/Plain JSON
- core.lua: 每订阅随机 token、generate_link(token, target)
- controller: 公开下载端点 /substore/download?token=&target= (token 访问控制)
- view: output.htm 13 格式下拉、subscriptions.htm 展示可复制的订阅链接
- Makefile: 通配安装新增 .lua 模块
- tests: output_formats_test.lua、core_link_test.lua
- Stage 1: subscription CRUD + download/parse core
- util.lua: JSON/Base64/URL/file helpers, atomic_write
- node.lua: protocol normalize
- parser.lua: vmess/vless/trojan/ss URI parse
- http.lua: curl/wget download with SSRF check
- core.lua: subscription meta/nodes persistence, sync()
- LuCI: subscriptions list & form, controller actions
- tests/run_tests.lua added
- Stage 0 skeleton: package scaffolding, LuCI menu placeholder, docs.
## [0.1.0] - not yet released
- Initial package skeleton (in development).
+344
View File
@@ -0,0 +1,344 @@
luci-app-substore
Copyright (C) 2026 Arthur97172
Licensed under the GNU General Public License, version 2 or (at your option)
any later version (GPL-2.0-or-later). The complete license text follows.
GNU GENERAL PUBLIC LICENSE
Version 2, June 1991
Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
<https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The licenses for most software are designed to take away your
freedom to share and change it. By contrast, the GNU General Public
License is intended to guarantee your freedom to share and change free
software--to make sure the software is free for all its users. This
General Public License applies to most of the Free Software
Foundation's software and to any other program whose authors commit to
using it. (Some other Free Software Foundation software is covered by
the GNU Lesser General Public License instead.) You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
this service if you wish), that you receive source code or can get it
if you want it, that you can change the software or use pieces of it
in new free programs; and that you know you can do these things.
To protect your rights, we need to make restrictions that forbid
anyone to deny you these rights or to ask you to surrender the rights.
These restrictions translate to certain responsibilities for you if you
distribute copies of the software, or if you modify it.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must give the recipients all the rights that
you have. You must make sure that they, too, receive or can get the
source code. And you must show them these terms so they know their
rights.
We protect your rights with two steps: (1) copyright the software, and
(2) offer you this license which gives you legal permission to copy,
distribute and/or modify the software.
Also, for each author's protection and ours, we want to make certain
that everyone understands that there is no warranty for this free
software. If the software is modified by someone else and passed on, we
want its recipients to know that what they have is not the original, so
that any problems introduced by others will not reflect on the original
authors' reputations.
Finally, any free program is threatened constantly by software
patents. We wish to avoid the danger that redistributors of a free
program will individually obtain patent licenses, in effect making the
program proprietary. To prevent this, we have made it clear that any
patent must be licensed for everyone's free use or not licensed at all.
The precise terms and conditions for copying, distribution and
modification follow.
GNU GENERAL PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. This License applies to any program or other work which contains
a notice placed by the copyright holder saying it may be distributed
under the terms of this General Public License. The "Program", below,
refers to any such program or work, and a "work based on the Program"
means either the Program or any derivative work under copyright law:
that is to say, a work containing the Program or a portion of it,
either verbatim or with modifications and/or translated into another
language. (Hereinafter, translation is included without limitation in
the term "modification".) Each licensee is addressed as "you".
Activities other than copying, distribution and modification are not
covered by this License; they are outside its scope. The act of
running the Program is not restricted, and the output from the Program
is covered only if its contents constitute a work based on the
Program (independent of having been made by running the Program).
Whether that is true depends on what the Program does.
1. You may copy and distribute verbatim copies of the Program's
source code as you receive it, in any medium, provided that you
conspicuously and appropriately publish on each copy an appropriate
copyright notice and disclaimer of warranty; keep intact all the
notices that refer to this License and to the absence of any warranty;
and give any other recipients of the Program a copy of this License
along with the Program.
You may charge a fee for the physical act of transferring a copy, and
you may at your option offer warranty protection in exchange for a fee.
2. You may modify your copy or copies of the Program or any portion
of it, thus forming a work based on the Program, and copy and
distribute such modifications or work under the terms of Section 1
above, provided that you also meet all of these conditions:
a) You must cause the modified files to carry prominent notices
stating that you changed the files and the date of any change.
b) You must cause any work that you distribute or publish, that in
whole or in part contains or is derived from the Program or any
part thereof, to be licensed as a whole at no charge to all third
parties under the terms of this License.
c) If the modified program normally reads commands interactively
when run, you must cause it, when started running for such
interactive use in the most ordinary way, to print or display an
announcement including an appropriate copyright notice and a
notice that there is no warranty (or else, saying that you provide
a warranty) and that users may redistribute the program under
these conditions, and telling the user how to view a copy of this
License. (Exception: if the Program itself is interactive but
does not normally print such an announcement, your work based on
the Program is not required to print an announcement.)
These requirements apply to the modified work as a whole. If
identifiable sections of that work are not derived from the Program,
and can be reasonably considered independent and separate works in
themselves, then this License, and its terms, do not apply to those
sections when you distribute them as separate works. But when you
distribute the same sections as part of a whole which is a work based
on the Program, the distribution of the whole must be on the terms of
this License, whose permissions for other licensees extend to the
entire whole, and thus to each and every part regardless of who wrote it.
Thus, it is not the intent of this section to claim rights or contest
your rights to work written entirely by you; rather, the intent is to
exercise the right to control the distribution of derivative or
collective works based on the Program.
In addition, mere aggregation of another work not based on the Program
with the Program (or with a work based on the Program) on a volume of
a storage or distribution medium does not bring the other work under
the scope of this License.
3. You may copy and distribute the Program (or a work based on it,
under Section 2) in object code or executable form under the terms of
Sections 1 and 2 above provided that you also do one of the following:
a) Accompany it with the complete corresponding machine-readable
source code, which must be distributed under the terms of Sections
1 and 2 above on a medium customarily used for software interchange; or,
b) Accompany it with a written offer, valid for at least three
years, to give any third party, for a charge no more than your
cost of physically performing source distribution, a complete
machine-readable copy of the corresponding source code, to be
distributed under the terms of Sections 1 and 2 above on a medium
customarily used for software interchange; or,
c) Accompany it with the information you received as to the offer
to distribute corresponding source code. (This alternative is
allowed only for noncommercial distribution and only if you
received the program in object code or executable form with such
an offer, in accord with Subsection b above.)
The source code for a work means the preferred form of the work for
making modifications to it. For an executable work, complete source
code means all the source code for all modules it contains, plus any
associated interface definition files, plus the scripts used to
control compilation and installation of the executable. However, as a
special exception, the source code distributed need not include
anything that is normally distributed (in either source or binary
form) with the major components (compiler, kernel, and so on) of the
operating system on which the executable runs, unless that component
itself accompanies the executable.
If distribution of executable or object code is made by offering
access to copy from a designated place, then offering equivalent
access to copy the source code from the same place counts as
distribution of the source code, even though third parties are not
compelled to copy the source along with the object code.
4. You may not copy, modify, sublicense, or distribute the Program
except as expressly provided under this License. Any attempt
otherwise to copy, modify, sublicense or distribute the Program is
void, and will automatically terminate your rights under this License.
However, parties who have received copies, or rights, from you under
this License will not have their licenses terminated so long as such
parties remain in full compliance.
5. You are not required to accept this License, since you have not
signed it. However, nothing else grants you permission to modify or
distribute the Program or its derivative works. These actions are
prohibited by law if you do not accept this License. Therefore, by
modifying or distributing the Program (or any work based on the
Program), you indicate your acceptance of this License to do so, and
all its terms and conditions for copying, distributing or modifying
the Program or works based on it.
6. Each time you redistribute the Program (or any work based on the
Program), the recipient automatically receives a license from the
original licensor to copy, distribute or modify the Program subject to
these terms and conditions. You may not impose any further
restrictions on the recipients' exercise of the rights granted herein.
You are not responsible for enforcing compliance by third parties to
this License.
7. If, as a consequence of a court judgment or allegation of patent
infringement or for any other reason (not limited to patent issues),
conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot
distribute so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you
may not distribute the Program at all. For example, if a patent
license would not permit royalty-free redistribution of the Program by
all those who receive copies directly or indirectly through you, then
the only way you could satisfy both it and this License would be to
refrain entirely from distribution of the Program.
If any portion of this section is held invalid or unenforceable under
any particular circumstance, the balance of the section is intended to
apply and the section as a whole is intended to apply in other
circumstances.
It is not the purpose of this section to induce you to infringe any
patents or other property right claims or to contest validity of any
such claims; this section has the sole purpose of protecting the
integrity of the free software distribution system, which is
implemented by public license practices. Many people have made
generous contributions to the wide range of software distributed
through that system in reliance on consistent application of that
system; it is up to the author/donor to decide if he or she is willing
to distribute software through any other system and a licensee cannot
impose that choice.
This section is intended to make thoroughly clear what is believed to
be a consequence of the rest of this License.
8. If the distribution and/or use of the Program is restricted in
certain countries either by patents or by copyrighted interfaces, the
original copyright holder who places the Program under this License
may add an explicit geographical distribution limitation excluding
those countries, so that distribution is permitted only in or among
countries not thus excluded. In such case, this License incorporates
the limitation as if written in the body of this License.
9. The Free Software Foundation may publish revised and/or new versions
of the General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the Program
specifies a version number of this License which applies to it and "any
later version", you have the option of following the terms and conditions
either of that version or of any later version published by the Free
Software Foundation. If the Program does not specify a version number of
this License, you may choose any version ever published by the Free Software
Foundation.
10. If you wish to incorporate parts of the Program into other free
programs whose distribution conditions are different, write to the author
to ask for permission. For software which is copyrighted by the Free
Software Foundation, write to the Free Software Foundation; we sometimes
make exceptions for this. Our decision will be guided by the two goals
of preserving the free status of all derivatives of our free software and
of promoting the sharing and reuse of software generally.
NO WARRANTY
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
REPAIR OR CORRECTION.
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
POSSIBILITY OF SUCH DAMAGES.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
convey the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License along
with this program; if not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program is interactive, make it output a short notice like this
when it starts in an interactive mode:
Gnomovision version 69, Copyright (C) year name of author
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, the commands you use may
be called something other than `show w' and `show c'; they could even be
mouse-clicks or menu items--whatever suits your program.
You should also get your employer (if you work as a programmer) or your
school, if any, to sign a "copyright disclaimer" for the program, if
necessary. Here is a sample; alter the names:
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
`Gnomovision' (which makes passes at compilers) written by James Hacker.
<signature of Moe Ghoul>, 1 April 1989
Moe Ghoul, President of Vice
This General Public License does not permit incorporating your program into
proprietary programs. If your program is a subroutine library, you may
consider it more useful to permit linking proprietary applications with the
library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License.
+92
View File
@@ -0,0 +1,92 @@
#
# Copyright (C) 2026 luci-app-substore
#
# This is free software, licensed under the GNU General Public License v2 or later.
#
include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-substore
# 版本约定:每次提交 PKG_RELEASE +1(2.1.2-r1 ~ r9);
# 达到 r10 时 PKG_VERSION 末位 +1(2.1.2 -> 2.1.3),PKG_RELEASE 重置为 1。
PKG_VERSION:=2.1.3
PKG_RELEASE:=1
LUCI_DEPENDS:=+luci-lua-runtime +luci-compat
include $(INCLUDE_DIR)/package.mk
include $(TOPDIR)/feeds/luci/luci.mk
define Package/luci-app-substore
SECTION:=luci
CATEGORY:=LuCI
SUBMENU:=3. Applications
TITLE:=Airport subscription manager (Sub-Store like)
PKG_MAINTAINER:=Arthur97172 <arthur97172@outlook.com>
PKGARCH:=all
DEPENDS:=$(LUCI_DEPENDS)
endef
define Package/luci-app-substore/description
Native OpenWrt / ImmortalWrt LuCI application for managing
airport subscriptions, parsing proxy nodes, filtering, deduplicating,
merging and generating client subscription configs.
Sub-Store like experience, without Docker or external services.
endef
define Build/Prepare
endef
define Build/Configure
endef
define Build/Compile
endef
define Package/luci-app-substore/install
$(INSTALL_DIR) $(1)/etc/config
$(INSTALL_CONF) ./root/etc/config/substore $(1)/etc/config/substore
$(INSTALL_DIR) $(1)/etc/uci-defaults
$(INSTALL_BIN) ./root/etc/uci-defaults/99-substore $(1)/etc/uci-defaults/99-substore
$(INSTALL_DIR) $(1)/usr/bin
$(INSTALL_BIN) ./root/usr/bin/substore-cron.sh $(1)/usr/bin/substore-cron.sh
$(INSTALL_DIR) $(1)/usr/lib/lua/substore
$(INSTALL_DATA) ./root/usr/share/substore/*.lua $(1)/usr/lib/lua/substore/
$(INSTALL_DIR) $(1)/usr/lib/lua/luci/controller/admin
$(INSTALL_DATA) ./root/usr/lib/lua/luci/controller/admin/substore.lua $(1)/usr/lib/lua/luci/controller/admin/substore.lua
$(INSTALL_DIR) $(1)/usr/lib/lua/luci/view/substore
$(INSTALL_DATA) ./root/usr/lib/lua/luci/view/substore/subscriptions.htm $(1)/usr/lib/lua/luci/view/substore/subscriptions.htm
$(INSTALL_DATA) ./root/usr/lib/lua/luci/view/substore/form.htm $(1)/usr/lib/lua/luci/view/substore/form.htm
$(INSTALL_DATA) ./root/usr/lib/lua/luci/view/substore/local_form.htm $(1)/usr/lib/lua/luci/view/substore/local_form.htm
$(INSTALL_DATA) ./root/usr/lib/lua/luci/view/substore/nodes.htm $(1)/usr/lib/lua/luci/view/substore/nodes.htm
$(INSTALL_DATA) ./root/usr/lib/lua/luci/view/substore/node_edit.htm $(1)/usr/lib/lua/luci/view/substore/node_edit.htm
$(INSTALL_DIR) $(1)/www/luci-static/resources/substore
$(INSTALL_DATA) ./root/www/luci-static/resources/substore/nodeform.js $(1)/www/luci-static/resources/substore/nodeform.js
$(INSTALL_DATA) ./root/usr/lib/lua/luci/view/substore/output.htm $(1)/usr/lib/lua/luci/view/substore/output.htm
$(INSTALL_DATA) ./root/usr/lib/lua/luci/view/substore/combo.htm $(1)/usr/lib/lua/luci/view/substore/combo.htm
$(INSTALL_DIR) $(1)/usr/share/luci/menu.d
$(INSTALL_DATA) ./root/usr/share/luci/menu.d/luci-app-substore.json $(1)/usr/share/luci/menu.d/luci-app-substore.json
# 简体中文翻译(运行时 zh-cn 时由 luci.template.parser 自动加载 *.zh-cn.lmo)
$(INSTALL_DIR) $(1)/usr/lib/lua/luci/i18n
po2lmo ./po/zh-cn/substore.po $(1)/usr/lib/lua/luci/i18n/substore.zh-cn.lmo
endef
define Package/luci-app-substore/postinst
#!/bin/sh
[ -n "$${IPKG_INSTROOT}" ] || {
# 刷新 LuCI 缓存(现代 OpenWrt 已移除 lua 时代的 /lib/functions/luci.sh)
rm -f /tmp/luci-indexcache
[ -f /etc/init.d/luci ] && /etc/init.d/luci reload
exit 0
}
endef
$(eval $(call BuildPackage,luci-app-substore))
+156
View File
@@ -0,0 +1,156 @@
# luci-app-substore
**English** | [简体中文](README.md)
Native **OpenWrt / ImmortalWrt** LuCI application for managing airport / proxy
subscriptions. Parse nodes from a subscription, filter, deduplicate, rename and
group them, then re-emit them in a format your client can consume.
## Features
**Subscription management**
- Add / edit / delete / update multiple subscription sources
- Manual update plus per-subscription timed (cron) updates
- Status overview: node count, last update time, error message
- Remaining traffic & remaining duration per subscription, parsed from the
`subscription-userinfo` response header (shown on the edit page only)
- **Subscription proxy**: download the subscription through an
`http://` / `https://` / `socks4` / `socks5` / `socks5h` proxy — useful when the
source is unreachable directly
- **Combination subscription**: merge an arbitrary subset of existing subscriptions
(optionally with keyword include / exclude and dedup rules) into one combination that
has its own name, token and subscription link; combinations are recomputed automatically
when a source updates
**Input parsing**
- Subscription formats: URI lists, Base64, JSON, Clash YAML, sing-box JSON,
V2Ray / Xray JSON, Surge / Surfboard / Loon / Quantumult X configs, and LAN
subscription links
- Node protocols: `vmess` / `vless` / `trojan` / `shadowsocks` / `ssr` / `hysteria2` /
`tuic` / `socks` (and more)
**Node processing**
- Browse nodes, filter by protocol, keyword search, sort
- Per-subscription rules applied on every update:
- keyword include / exclude (comma-separated, multi-keyword)
- deduplication
**Network probing** (Nodes page)
- Ping (ICMP latency), TCPing (connect latency), URL test (HTTP latency)
- Parallel probing, success count and average latency
**Conversion & output**
- Protocol conversion: any node type → any other type
- SSR (`ssr://`) input is re-emitted losslessly to SSR-capable clients only — Mihomo /
Clash.Meta, Stash, Loon, Egern, Shadowrocket — and dropped for the rest (sing-box,
V2Ray/Xray, Surge family), since SSR is not convertible to/from other protocols
- 13 output formats (all implemented): Plain JSON, Stash, Clash.Meta / Mihomo YAML,
Surfboard, Surge, Surge Mac, Loon, Egern, Shadowrocket, Quantumult X, sing-box,
V2Ray / Xray, V2Ray URI
**Subscription links**
- Per-subscription random token → public download endpoint
`/substore/download?token=<token>&target=<format>` that Passwall / OpenClash and
other clients can pull directly
**LuCI web UI & i18n**
- English by default, 简体中文 auto-selected when the runtime language is `zh-cn`
## Installation
> The version in the package name must match `PKG_VERSION` / `PKG_RELEASE` in the
> [Makefile](Makefile) (currently `2.1.3-r6`).
opkg (OpenWrt / ImmortalWrt 24.10 and earlier):
```bash
opkg install luci-app-substore-2.1.3-r6.ipk
```
apk (OpenWrt / ImmortalWrt 25.12+):
```bash
apk add --allow-untrusted luci-app-substore-2.1.3-r6.apk
```
Then open LuCI: **Services → Subscriptions**.
## Usage
1. **Add a subscription** — paste the subscription URL; optionally set up a
per-subscription cron schedule, rules, or a download proxy.
2. **Update** — fetch, parse and filter the nodes.
3. **Browse nodes** — filter, sort, and probe latency.
4. **Export** — pick one of the 13 output formats, or copy the subscription link
to feed a downstream client (Passwall / OpenClash / …).
## Project layout
```
.
├── Makefile # OpenWrt package definition
├── LICENSE # GPL-2.0-or-later
├── root/ # install payload
│ ├── etc/
│ │ ├── config/substore # UCI placeholder
│ │ └── uci-defaults/99-substore
│ ├── usr/
│ │ ├── bin/substore-cron.sh # per-subscription cron runner
│ │ ├── lib/lua/luci/
│ │ │ ├── controller/admin/substore.lua # routes / actions
│ │ │ └── view/substore/*.htm # templates
│ │ └── share/
│ │ ├── luci/menu.d/luci-app-substore.json
│ │ └── substore/*.lua # core logic (no luci.* dependency)
├── po/zh-cn/substore.po # 简体中文 translations
├── docs/ # design & guides
└── tests/ # self-contained Lua 5.1 unit tests
```
## Documentation
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) — architecture design
- [docs/PLAN.md](docs/PLAN.md) — staged development plan
- [docs/BUILD.md](docs/BUILD.md) — building from an OpenWrt SDK/source tree
- [docs/INSTALL.md](docs/INSTALL.md) — installation
- [docs/SECURITY.md](docs/SECURITY.md) — security model
- [docs/TESTING.md](docs/TESTING.md) — testing
- [docs/UCODE_MIGRATION.md](docs/UCODE_MIGRATION.md) — `.htm` → `.ut` (ucode) migration notes
- [CHANGELOG.md](CHANGELOG.md) — changelog
## Building
Place the package under an OpenWrt / ImmortalWrt SDK or source tree matching the
target firmware, then:
```bash
cp -r luci-app-substore <openwrt-tree>/package/
make package/luci-app-substore/compile V=s
```
The `.ipk` (or `.apk` on apk builds) is produced under `bin/packages/.../`.
## Development & testing
The core is plain Lua 5.1 with no `luci.*` dependency, so it is unit-testable
without a device. Each file under `tests/` is self-contained:
```bash
lua5.1 tests/run_tests.lua # or any single test file
for f in tests/*.lua; do lua5.1 "$f" || exit 1; done
```
Target-device verification is required for the LuCI UI and cron behaviour — see
[docs/TESTING.md](docs/TESTING.md).
## Security
SSRF protection (private / reserved / link-local ranges rejected), protocol
whitelisting, response size & timeout limits, command-injection defence
(whitelisted parsing + shell quoting), token-based access control on the public
download endpoint, and no credentials in logs. See
[docs/SECURITY.md](docs/SECURITY.md).
## License
[GPL-2.0-or-later](LICENSE) — see the [LICENSE](LICENSE) file.
+144
View File
@@ -0,0 +1,144 @@
# luci-app-substore
**简体中文** | [English](README.en.md)
原生 **OpenWrt / ImmortalWrt** LuCI 应用,用于管理机场 / 代理订阅:解析订阅节点、
筛选、去重、重命名与分组,再转换为客户端可用的配置格式输出。
参考 [Sub-Store](https://github.com/sub-store-org/Sub-Store) 的功能与用户体验,
独立设计与实现:不使用 Docker,不依赖外部云端服务,资源占用友好,适配低配置路由器。
## 功能特性
**订阅管理**
- 新增 / 编辑 / 删除 / 更新多个订阅源
- 手动更新 + 按订阅的定时(cron)更新
- 状态总览:节点数、最近更新时间、错误信息
- 每个订阅的剩余流量 / 剩余时长,从 `subscription-userinfo` 响应头解析(仅在编辑页显示)
- **订阅代理**:通过 `http://` / `https://` / `socks4` / `socks5` / `socks5h` 代理下载订阅,
用于订阅源直连失败时
- **组合订阅**:勾选任意子集的现有订阅(可叠加关键词包含 / 排除、去重规则)合并成一个组合,
拥有独立名称、token 与订阅链接;源订阅更新后组合自动重算
**输入解析**
- 订阅格式:URI 列表、Base64、JSON、Clash YAML、sing-box JSON、V2Ray / Xray JSON、
Surge / Surfboard / Loon / Quantumult X 配置、局域网订阅链接
- 节点协议:`vmess` / `vless` / `trojan` / `shadowsocks` / `ssr` / `hysteria2` / `tuic` / `socks`(及更多)
**节点处理**
- 浏览节点,按协议筛选、关键词搜索、排序
- 每次更新时生效的按订阅规则:
- 关键词包含 / 排除(逗号分隔,支持多关键词)
- 去重
**网络探测**(节点页)
- Ping(ICMP 延迟)、TCPing(连接延迟)、URL 测试(HTTP 延迟)
- 并行探测,显示成功数与平均延迟
**转换与输出**
- 协议转换:任意协议 → 任意协议
- SSR(`ssr://`)订阅源支持:仅能原样输出到支持它的客户端(Mihomo / Clash.Meta、Stash、
Loon、Egern、Shadowrocket),对其余目标(sing-box、V2Ray/Xray、Surge 家族)丢弃;SSR
不能与 vmess/vless 等其它协议互转(协议不兼容)
- 13 种输出格式(全部实现):Plain JSON、Stash、Clash.Meta / Mihomo YAML、Surfboard、
Surge、Surge Mac、Loon、Egern、Shadowrocket、Quantumult X、sing-box、V2Ray / Xray、
V2Ray URI
**订阅链接**
- 每个订阅独立随机 token → 公开下载端点
`/substore/download?token=<token>&target=<format>`,Passwall / OpenClash 等客户端可直接拉取
**LuCI 界面与国际化**
- 默认英文,运行时语言为 `zh-cn` 时自动显示简体中文
## 安装
> 包名中的版本号必须与 [Makefile](Makefile) 的 `PKG_VERSION` / `PKG_RELEASE` 保持一致
> (当前 `2.1.3-r6`)。
opkg(OpenWrt / ImmortalWrt 24.10 及更早):
```bash
opkg install luci-app-substore-2.1.3-r6.ipk
```
apk(OpenWrt / ImmortalWrt 25.12+):
```bash
apk add --allow-untrusted luci-app-substore-2.1.3-r6.apk
```
然后在 LuCI 菜单打开:**服务 → 订阅**。
## 使用方法
1. **添加订阅** —— 粘贴订阅 URL;可选的按订阅 cron 定时、规则或下载代理。
2. **更新** —— 下载、解析并过滤节点。
3. **浏览节点** —— 筛选、排序、探测延迟。
4. **导出** —— 任选 13 种格式之一,或复制订阅链接供下游客户端(Passwall / OpenClash / …)使用。
## 目录结构
```
.
├── Makefile # OpenWrt 包定义
├── LICENSE # GPL-2.0-or-later
├── root/ # 安装内容
│ ├── etc/
│ │ ├── config/substore # UCI 占位
│ │ └── uci-defaults/99-substore
│ ├── usr/
│ │ ├── bin/substore-cron.sh # 按订阅 cron 执行脚本
│ │ ├── lib/lua/luci/
│ │ │ ├── controller/admin/substore.lua # 路由 / 动作
│ │ │ └── view/substore/*.htm # 模板
│ │ └── share/
│ │ ├── luci/menu.d/luci-app-substore.json
│ │ └── substore/*.lua # 核心逻辑(不依赖 luci.*)
├── po/zh-cn/substore.po # 简体中文翻译
├── docs/ # 设计与指南
└── tests/ # 自包含 Lua 5.1 单元测试
```
## 文档
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) — 架构设计
- [docs/PLAN.md](docs/PLAN.md) — 分阶段开发计划
- [docs/BUILD.md](docs/BUILD.md) — 从 OpenWrt SDK / 源码树构建
- [docs/INSTALL.md](docs/INSTALL.md) — 安装
- [docs/SECURITY.md](docs/SECURITY.md) — 安全模型
- [docs/TESTING.md](docs/TESTING.md) — 测试
- [docs/UCODE_MIGRATION.md](docs/UCODE_MIGRATION.md) — `.htm` → `.ut`(ucode)迁移说明
- [CHANGELOG.md](CHANGELOG.md) — 更新日志
## 构建
将本包放入与目标固件版本匹配的 OpenWrt / ImmortalWrt SDK 或源码树:
```bash
cp -r luci-app-substore <openwrt-tree>/package/
make package/luci-app-substore/compile V=s
```
`.ipk`(或 apk 构建下的 `.apk`)生成于 `bin/packages/.../` 下。
## 开发与测试
核心逻辑为纯 Lua 5.1,不依赖 `luci.*`,无需设备即可单元测试。`tests/` 下每个测试文件自包含:
```bash
lua5.1 tests/run_tests.lua # 或任意单个测试文件
for f in tests/*.lua; do lua5.1 "$f" || exit 1; done
```
LuCI 界面与 cron 行为仍需在目标设备上验证 —— 见 [docs/TESTING.md](docs/TESTING.md)。
## 安全
SSRF 防护(拒绝内网 / 保留 / 链路本地地址)、协议白名单、响应体大小与超时限制、
命令注入防护(白名单解析 + shell 引用)、公开下载端点基于 token 的访问控制、日志不含凭据。
详见 [docs/SECURITY.md](docs/SECURITY.md)。
## 许可证
[GPL-2.0-or-later](LICENSE) —— 见 [LICENSE](LICENSE) 文件。
+103
View File
@@ -0,0 +1,103 @@
# 架构设计 — luci-app-substore
OpenWrt / ImmortalWrt 原生 LuCI 机场订阅管理工具。参考 Sub-Store 功能体验,独立设计与实现,不复制上游代码。
## 1. 目标与范围
原生 OpenWrt 软件包,不使用 Docker,不依赖外部云端服务。在低性能路由器(内存 ≤ 512MB,存储紧张)上运行。
功能范围:
- 订阅管理:多订阅源增删改查、手动/定时更新、更新时间/节点数/状态展示
- 节点管理:解析常见代理协议、查看/搜索/筛选/排序/重命名/去重
- 订阅处理:按条件筛选、去重、重命名、排序、多订阅合并、生成客户端订阅
- 输出格式:13 种全量实现(Plain JSON / Stash / Clash.Meta / Surfboard / Surge / SurgeMac / Loon / Egern / Shadowrocket / QX / sing-box / V2Ray / V2Ray URI)
- 订阅链接:转换结果以订阅链接形式下发,供 Passwall / OpenClash 等拉取
- LuCI 界面:订阅列表、编辑更新、节点查看、规则配置、输出复制、状态日志
## 2. 技术选型
| 层 | 选型 | 理由 |
|----|------|------|
| 后端核心 | **Lua 5.1 纯脚本库** | 用户选定;不依赖 luci.*,可独立测试;跨 LuCI 版本稳定 |
| LuCI 前端 | **Lua 兼容模式 (luci-lua-runtime)** | 用户选定;在 23.05+/24.10+/25.12 以 Lua 兼容层运行 |
| 数据存储 | 独立数据文件 `/etc/substore/*.json` | CLAUDE.md 要求订阅/状态用独立文件,不用 UCI 存节点数据 |
| 配置 | UCI `/etc/config/substore` | 仅存订阅源元数据、处理规则、输出设置 |
| 后台服务 | procd(仅当需要常驻/定时时) | 定时更新可走 LuCI 页面触发 + cron,避免常驻 |
| 输出接口 | LuCI 路由 / CGI,带随机 Token 鉴权 | CLAUDE.md 安全要求 |
## 3. 模块划分
```
luci-app-substore/
├── Makefile # 软件包构建
├── root/
│ ├── etc/config/substore # UCI 配置
│ ├── usr/lib/lua/luci/ # LuCI 前端(controller / model / view)
│ └── usr/share/substore/ # 纯 Lua 核心库 + 数据目录
├── src/ # 可选:需编译的辅助(暂无)
└── docs/ # 文档
```
核心库(`/usr/share/substore/`,纯 Lua,不依赖 luci.*):
- `core.lua` — 订阅源元数据读写、状态管理、订阅链接生成(token + generate_link)
- `parser.lua` — 订阅格式解析(Base64/URI/Clash YAML/JSON)
- `node.lua` — 节点模型、筛选、去重、重命名、排序
- `node_converter.lua` — 协议间字段映射与协议转换
- `converter.lua` — 通用转换器(字段映射、URL 模板渲染)
- `output.lua` — 统一格式分发(FORMAT_ALIASES 别名映射、content-type/扩展名)
- `output_clash_meta.lua` — Clash.Meta / Mihomo / Stash YAML
- `output_uri.lua` — 分享链接 URI / Shadowrocket / V2Ray URI
- `output_singbox.lua` — sing-box JSON
- `output_v2ray.lua` — V2Ray/Xray JSON
- `output_formats.lua` — Surge 系 / Loon / QX / Egern / Plain JSON
- `http.lua` — 订阅下载(SSRF 防护、超时、大小限制)
- `util.lua` — 通用工具(原子写、时间、日志、Base64、JSON)
LuCI 前端(`/usr/lib/lua/luci/`):
- `controller/admin/substore.lua` — 路由注册
- `model/cbi/substore/*.lua` — 配置界面
- `view/substore/*.htm` — 页面模板
## 4. 数据流
订阅 URL / 本地文本 → `http.lua` 下载(SSRF 防护)→ `parser.lua` 解析 → `node.lua` 统一节点模型 → 去重/筛选/重命名/排序 → `output.lua` 按目标格式生成 → LuCI 界面 / 订阅下载端点
**订阅链接下发流程**:每个订阅在创建时生成随机 16 位十六进制 `token`,存入元数据。客户端请求公开端点
`GET /substore/download?token=<token>&target=<format>`(如 `target=ClashMeta`),无需登录态、靠不可猜测的 token 鉴权,
按 `target` 实时转换节点并返回(`node_converter.lua` 负责协议间转换,`output.*` 负责格式生成)。
节点统一内部模型(协议无关):
```lua
{
proto = "vmess"|"vless"|"trojan"|"shadowsocks"|"hysteria2"|"tuic"|"ss"|...,
name = "节点名",
server = "host",
port = 443,
uuid = "uuid", password = "...", cipher = "...", -- 协议特有字段
raw = "原始URI串" -- 保留原始,便于无损转发
}
```
## 5. 安全设计(CLAUDE.md 第 6 节)
- **SSRF 防护**:`http.lua` 下载前解析主机名,拒绝 localhost/内网/链路本地/保留地址;DNS 解析后二次校验;处理重定向
- **资源限制**:订阅响应体最大大小(默认 10MB 可配)、连接/总超时、并发限制
- **路径穿越**:订阅名/文件名做白名单校验,禁止 `..`、`/`
- **命令注入**:不将用户输入拼入 shell;需要时用参数化执行
- **凭据保护**:订阅 URL 中的 token/密码不写入普通日志;输出接口用随机 Token 鉴权
- **导入校验**:解析时对结构/大小/嵌套深度做限制,防恶意 YAML/JSON 资源耗尽
- 所有安全策略结合 OpenWrt 实际网络环境实现并编写测试
## 6. 兼容性约束
- 目标 LuCI 使用 Lua 兼容模式,需依赖 `luci-lua-runtime` + `luci-compat`(23.05+)
- Lua 控制器沿用 `module()` + `entry()` 风格(24.10 有效,活跃插件仍在使用)
- **25.12 风险**:LuCI 模板优先级已转向 `.ut`(ucode),`.htm` 为 Legacy。阶段4 已评估迁移方案,详见 `docs/UCODE_MIGRATION.md`(含 `.htm`→`.ut` 语法对照与逐模板映射;核心库纯 Lua 可通过 `include()` 复用,迁移成本集中在 5 个模板的胶水代码)
- 不假设所有 OpenWrt 版本依赖一致,分版本适配
- 纯 Lua 核心库优先用 Lua 5.1 标准库(luci 自带 lua5.1),避免 luarocks 依赖
- 如需要 YAML 解析:优先评估纯 Lua 实现或 luci 内置,避免重运行时
- `/lib/functions/luci.sh` 已移除,postinst 用 `rm -f /tmp/luci-indexcache` + `/etc/init.d/luci reload` 刷新缓存
## 7. 文档清单
README.md、docs/ARCHITECTURE.md、docs/BUILD.md、docs/INSTALL.md、docs/SECURITY.md、docs/TESTING.md、CHANGELOG.md
+22
View File
@@ -0,0 +1,22 @@
# Building — luci-app-substore
## Prerequisites
An OpenWrt / ImmortalWrt SDK or full source tree matching the target firmware version.
## Build
Place this package under the OpenWrt tree:
```bash
cd <openwrt-sdk-or-source>
# copy or symlink the package
cp -r <path>/luci-app-substore package/
make package/luci-app-substore/compile V=s
```
The `.ipk` (or `.apk` on apk-based builds) is produced under
`bin/packages/.../luci-app-substore_*.ipk`.
> NOTE: stage-0 skeleton has not yet been built/verified on a target device.
> This document will be updated once the build chain is validated (see docs/PLAN.md stage 0).
+27
View File
@@ -0,0 +1,27 @@
# Installation — luci-app-substore
> NOTE: stage-0 skeleton has not yet been installed/verified on a target device.
> This document will be updated once installation is validated.
>
> 包名中的版本号需与 Makefile 的 `PKG_VERSION` / `PKG_RELEASE` 保持同步(当前 2.1.3-r6)。
## Install (opkg — OpenWrt / ImmortalWrt 24.10 及更早)
```bash
opkg install luci-app-substore-2.1.3-r6.ipk
```
## Install (apk — OpenWrt / ImmortalWrt 25.12+)
```bash
apk add --allow-untrusted luci-app-substore-2.1.3-r6.apk
```
Refresh LuCI:
```bash
rm -f /tmp/luci-indexcache
/etc/init.d/luci reload
```
Then open LuCI menu: **Services → Subscriptions** (or **System → Subscriptions** depending on firmware).
+99
View File
@@ -0,0 +1,99 @@
# 分阶段开发计划 — luci-app-substore
架构覆盖完整功能,交付分阶段进行。每阶段产出可编译、可安装、可验证的产物。每阶段遵循 CLAUDE.md 流程:说明目标 → 检查现有代码 → 列改动文件 → 小范围实施 → 静态检查/测试 → 检查 diff → 更新文档 → 汇报。
## 阶段 0 — 骨架与构建链路验证
**目标**:最小可编译、可安装的包骨架,跑通 OpenWrt 构建与安装全链路。
**产出**:
- Makefile(含 DEPENDS、安装路径、postinst)
- LuCI 菜单注册 + 一个占位页面(订阅列表空态)
- UCI 配置文件 `/etc/config/substore` + uci-defaults
- docs/BUILD.md、docs/INSTALL.md、README.md(骨架版)
**验收**:在目标 OpenWrt 设备上 `make package/luci-app-substore/compile` 成功、安装后 LuCI 出现菜单并可打开页面。
## 阶段 1 — 订阅源管理与下载解析
**目标**:订阅源 CRUD + URL 下载 + 本地粘贴导入 + 基础解析。
**产出**:
- `core.lua`:订阅源元数据读写(独立 JSON 数据文件)
- `http.lua`:SSRF 防护、超时、大小限制的下载器
- `parser.lua`:Base64/URI 订阅解析 → 统一节点模型(首批协议:vmess/vless/trojan/shadowsocks/ss)
- `node.lua`:节点模型基础
- LuCI 订阅列表页 + 添加/编辑/更新/删除
- UCI 存订阅源元数据;节点数据存独立文件
**验收**:添加真实订阅 → 手动更新 → 列表显示节点数、更新时间、解析成功状态。
## 阶段 2 — 节点处理与筛选
**目标**:节点查看、搜索、筛选、排序、重命名、去重。
**产出**:
- `node.lua`:筛选(协议/名称/关键词)、去重(同 server+port+proto)、重命名、排序
- LuCI 节点查看页(列表/搜索/筛选)
- 解析器测试样例
**验收**:对含重复、多协议的真实订阅,去重/筛选/重命名/排序结果正确。
## 阶段 3 — 多订阅合并与输出(订阅转换 + 订阅链接)
**目标**:任意类型节点转换为任意类型,13 种输出格式全量实现,转换结果以订阅链接形式下发。
**产出**:
- `output.lua`:统一格式分发(FORMAT_ALIASES 别名映射、content-type / 扩展名)
- `output_clash_meta.lua`:Clash.Meta / Mihomo / Stash YAML
- `output_uri.lua`:分享链接 URI / Shadowrocket(base64) / V2Ray URI
- `output_singbox.lua`:sing-box JSON
- `output_v2ray.lua`:V2Ray/Xray JSON
- `output_formats.lua`:Surge / Surfboard / SurgeMac / Loon / QX / Egern / Plain JSON
- `node_converter.lua` + `converter.lua`:协议间转换、URL 模板渲染
- `parser_surge.lua`:Surge / Loon / QX 客户端配置导入
- `parser.lua` 接线:sing-box / V2Ray / Clash JSON / Clash YAML / Surge / QX 输入源
- `core.generate_link(token, target)` + 每订阅随机 token
- LuCI 公开下载端点 `/substore/download?token=<token>&target=<format>`
- LuCI:output 页 13 格式下拉、subscriptions 页展示可复制的订阅链接
**验收**:合并多订阅生成 13 种格式,Passwall / OpenClash 可通过订阅链接拉取。
## 阶段 4 — 定时更新、规则、完善
**目标**:定时更新(cron)、规则配置、错误处理、安全加固、多版本兼容。
**产出**:
- 定时更新(cron / procd)
- 规则配置页
- 完整错误处理、日志
- 分版本依赖适配(23.05 / 24.10 / 25.12)
- **模板迁移:`.htm` → `.ut`(ucode),确保 25.12 兼容**(调研:25.12 模板优先级 `.ut` only)
- 订阅下载方式定案:curl vs Lua socket(影响 SSRF 防护实现,阶段1评估)
- docs/SECURITY.md、docs/TESTING.md、CHANGELOG.md
**验收**:定时更新生效、错误日志可读、各 OpenWrt 版本兼容、安全测试通过。
---
## 依赖关系
阶段 0 → 1 → 2 → 3 → 4(顺序推进,每阶段验证通过再进下一阶段)
## 当前状态
- [x] 调研:OpenWrt LuCI 24.10 有 luci-lua-runtime(Lua 兼容层),Lua 方案可行
- [x] 架构设计:docs/ARCHITECTURE.md
- [x] 分阶段计划:本文档
- [x] 阶段0:骨架构建链路验证通过(菜单位于 服务 → Subscriptions)
- [x] 阶段1:订阅 CRUD + 下载解析核心实现(core/util/node/parser/http + LuCI 列表/表单)
- [ ] 阶段1验证:用户在设备上添加真实订阅、手动更新、查看节点数/更新时间
- [x] 阶段2:节点处理与筛选(node.lua 筛选/去重/重命名/排序 + 分组/标签/重命名规则)
- [x] 阶段3:订阅转换 + 订阅链接(13 种输出格式、协议转换、输入源扩展、下载端点)
- [x] 阶段4:定时更新(cron)、规则配置、错误处理、安全加固、多版本兼容
- [x] cron 定时更新(substore-cron.sh 独立可运行、设置页 cron 配置)
- [x] 规则配置页(协议过滤/关键词/去重/重命名规则,含精确·正则·模板三形式)
- [x] 错误处理(core.sync 日志、action_update pcall 兜底、列表状态列展示错误)
- [x] 安全加固(下载文件名白名单防头注入;SSRF/大小/超时此前已实现)
- [x] 多版本兼容(LUCI_DEPENDS luci-lua-runtime + luci-compat)
- [ ] 模板迁移 `.htm` → `.ut`(ucode):已评估并写 docs/UCODE_MIGRATION.md,未执行——需 25.12 构建环境验证
+38
View File
@@ -0,0 +1,38 @@
# Security — luci-app-substore
## SSRF 防护
- `http.lua` 下载前解析主机名,拒绝 localhost/内网/链路本地/保留地址
- DNS 解析后二次校验,处理重定向
- 仅允许 http/https 协议
## 资源限制
- 订阅响应体最大 10MB,可配置
- 连接/总超时 20s
- 并发限制由系统调度
## 输入校验
- 订阅名/文件名白名单校验,禁止 `..`、`/`
- 解析时限制结构/大小/嵌套深度,防 YAML/JSON 资源耗尽
- URL 参数解码后二次校验
## 凭据保护
- 订阅 URL 中的 token 不写入普通日志
- 输出/下载接口使用**每订阅随机 16 位十六进制 token** 鉴权(`core.ensure_token`),不可猜测;`/substore/download` 无登录态
- 日志使用 `logger -t luci-app-substore`,不记录敏感信息
## 安全测试
- 已验证 SSRF 私网拒绝
- Base64/JSON 解析异常处理
- 超大响应体截断测试
参见 docs/ARCHITECTURE.md 第 5 节安全设计。
Key requirements (from CLAUDE.md):
- Prevent SSRF (reject localhost / private / link-local / reserved addresses)
- DNS rebinding protection
- Limit subscription response body size and set request timeouts
- Prevent path traversal and command injection
- Never log subscription credentials / tokens
- Output interface requires access control or a random token
- Validate imported data; prevent malicious YAML/JSON resource exhaustion
+37
View File
@@ -0,0 +1,37 @@
# Testing — luci-app-substore
## 单元测试
`tests/` 下每个测试文件均可独立运行,全部为纯 Lua 5.1:
| 测试文件 | 覆盖内容 |
|---------|---------|
| `run_tests.lua` | util(base64/json/url/hostport)、node、parser 基础 |
| `converter_*_test.lua` | 协议转换、URL 模板渲染 |
| `node_*_test.lua` | 节点模型扩展、分组、重命名 |
| `output_clash_meta_test.lua` | Clash.Meta / Mihomo YAML 生成 |
| `output_formats_test.lua` | 13 种目标格式统一分发 |
| `core_link_test.lua` | 订阅 token + generate_link 链接生成 |
| `parser_clash_yaml_test.lua` | Clash YAML 解析 |
| `parser_json_config_test.lua` | sing-box / V2Ray / Clash JSON 解析 |
| `parser_input_test.lua` | 多客户端配置导入(sing-box/V2Ray/Surge/QX) |
| `parser_local_link_test.lua` | 局域网订阅链接检测与解析 |
运行全部:`for f in tests/*.lua; do lua5.1 "$f" || exit 1; done`
## 集成测试
1. 编译安装到目标设备
2. 添加订阅 URL,手动更新,验证节点数
3. 节点浏览:筛选、排序
4. 输出生成:13 种格式下拉均可生成
5. 订阅链接:复制 `/substore/download?token=...&target=ClashMeta` 到 Passwall/OpenClash 验证可拉取
6. 输入源:导入 Clash YAML / sing-box JSON / V2Ray JSON / Surge / QX 配置验证解析
7. 定时更新:修改 Settings cron,验证 `/etc/cron.d/substore` 生成
8. 规则应用:设置协议过滤、关键词,验证节点列表/输出受影响
## 安全测试
- SSRF:尝试内网 URL,被拒绝
- 大响应:>10MB 被截断
- 恶意 Base64/JSON:解析失败不崩溃
## 回归测试
每次阶段发布前执行 `tests/run_tests.lua` 并在目标设备手动验证菜单/功能。
+90
View File
@@ -0,0 +1,90 @@
# 模板迁移评估:`.htm` (Lua) → `.ut` (ucode)
> **状态**:评估完成,未执行迁移。原因:本机无 OpenWrt 构建/运行环境,`.ut` 模板无法编译验证,冒然上线未经验证的 `.ut` 比保留 `.htm` 更危险(若 25.12 模板加载器优先 `.ut`,带 bug 的 `.ut` 会直接覆盖原本可用的 `.htm`)。
## 1. 背景
本项目的 LuCI 前端采用 **Lua 兼容模式**(`luci-lua-runtime` + `luci-compat`),模板为 `.htm`(Lua 代码 + `<% %>` / `<%= %>` / `<%: %>` 标签)。
| LuCI 版本 | 模板体系 | 本项目的 `.htm` |
|-----------|----------|------------------|
| 23.05 | Lua(`.htm`) | ✅ 原生支持 |
| 24.10 | ucode(`.ut`)为主,Lua 兼容层保留 `.htm` | ✅ 经 `luci-lua-runtime` |
| 25.12+ | 趋向 ucode(`.ut`),Lua 兼容层可能移除 | ⚠️ 风险点 |
调研结论(来自 LuCI 上游变更):24.10 起 `luci-base` 重写为 ucode,模板加载器优先查找 `.ut`;`.htm` 通过 Lua 兼容层继续工作。25.12 若彻底移除 `luci-lua-runtime`,则 `.htm` 失效,需 `.ut` 模板。
## 2. 涉及文件(5 个模板)
`root/usr/lib/lua/luci/view/substore/` 下:
- `subscriptions.htm` — 订阅列表(表格、token 链接、复制按钮、多表单)
- `form.htm` — 添加/编辑订阅
- `nodes.htm` — 节点查看(GET 表单筛选/排序)
- `output.htm` — 输出页(格式下拉、textarea)
- `settings.htm` — 设置页(cron 选择器、规则)
## 3. `.htm` → `.ut` 语法对照
| 事项 | Lua `.htm` | ucode `.ut` |
|------|-----------|-------------|
| 输出标签 | `<%=expr%>` | `<%= expr %>`(ucode 表达式)或 `{{= expr }}` |
| 翻译标签 | `<%:Text%>` | `<%:Text%>`(保留,语义一致) |
| 引入其它模板 | `<%+header%>` / `<%+footer%>` | `<% include("header") %>` / `<% include("footer") %>` |
| 取 HTTP 参数 | `require("luci.http")` + `http.formvalue(...)` | 预绑定全局 `http`,`http.formvalue(...)` |
| 取查询参数 | `luci.dispatcher.context.query` | 预绑定全局 `REQUEST`,`REQUEST.query` |
| 迭代数组 | `for _, v in ipairs(t) do ... end` | `for (let v in t) { ... }` |
| 条件 | `if cond then ... end` | `if (cond) { ... }` |
| 字符串拼接 | `..` | `+` |
| 三元 | `a and b or c`(有陷阱) | `cond ? a : b` |
| 取长度 | `#t` | `length(t)` |
| 索引 | `t[k]` / `t.k` | `t[k]` / `t.k` |
| 核心库调用 | `require("substore.core")` 后 `core.xxx()` | `core = include("substore/core.lua")` 后 `core.xxx()` |
## 4. 关键决策点
1. **核心库无需改写**:`/usr/share/substore/*.lua` 是纯 Lua,ucode 通过 `include()` 加载 Lua 文件并调用其函数(ucode 内置 Lua 互操作)。这是本方案最有利的一点——迁移成本集中在 5 个模板文件的“胶水代码”,核心逻辑(解析/转换/输出)不受影响。
2. **控制器**:`controller/admin/substore.lua` 目前用 `module()` + `entry()`。24.10 仍支持;若 25.12 要求 ucode 控制器(`.uc`),则需另写 `controller/admin/substore.uc`。与模板一样,属胶水层。
3. **数据存储与下载端点**:`core.lua`、`action_download` 逻辑与模板语言无关,无需迁移。
## 5. 建议的落地策略
按“能验证再迁移”原则,分步:
- **第 1 步(当前)**:保持 `.htm` + `luci-lua-runtime` 依赖,确保 23.05 / 24.10 可用。
- **第 2 步**:在真实 24.10 设备上安装,验证 `.htm` 经兼容层正常渲染。
- **第 3 步**:拿到 25.12 构建环境后,逐模板迁移为 `.ut`(可 `.htm` 与 `.ut` 并存,LuCI 优先 `.ut`),每个模板迁完即在设备上验证页面。
- **第 4 步**:25.12 若移除 Lua 运行时,同步评估控制器 `.uc` 迁移。
## 6. 示例迁移(subscriptions.htm 片段)
Lua 现状:
```html
<%
local core = require("substore.core")
local items = core.list()
for _, it in ipairs(items) do
%>
<tr><td><%=it.name%></td></tr>
<%
end
%>
```
ucode 等价:
```html
<%
let core = include("substore/core.lua");
let items = core.list();
for (let it in items) {
%>
<tr><td><%= it.name %></td></tr>
<%
}
%>
```
> 注:`include("substore/core.lua")` 的路径与 ucode 的 `LUCI_PATH` / `package.path` 解析规则有关,具体以 25.12 目标机的 `include` 路径解析为准,迁移时需在设备上确认。
+268
View File
@@ -0,0 +1,268 @@
# luci-app-substore 简体中文翻译
# 运行时语言代码 zh-cn(LUCI_LC_ALIAS.zh_Hans=zh-cn),
# 编译产物 substore.zh-cn.lmo 由 Makefile 的 install 步骤用 po2lmo 生成。
msgid ""
msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: zh-cn\n"
# 菜单 / 页面标题
msgid "Subscriptions"
msgstr "订阅管理"
msgid "Add subscription"
msgstr "添加订阅"
msgid "Edit subscription"
msgstr "编辑订阅"
msgid "Nodes"
msgstr "节点"
msgid "Output"
msgstr "输出"
msgid "Merged"
msgstr "合并"
# 通用按钮 / 标签
msgid "Back"
msgstr "返回"
msgid "Name"
msgstr "名称"
msgid "URL"
msgstr "订阅地址"
msgid "Updated"
msgstr "更新时间"
msgid "Status"
msgstr "状态"
msgid "Subscription link"
msgstr "订阅链接转换"
msgid "Actions"
msgstr "操作"
msgid "Save"
msgstr "保存"
msgid "Copy"
msgstr "复制"
msgid "Update"
msgstr "更新"
msgid "Edit"
msgstr "编辑"
msgid "Delete"
msgstr "删除"
msgid "Format"
msgstr "格式"
msgid "Generate"
msgstr "生成"
# 订阅列表
msgid "Pending"
msgstr "待更新"
msgid "OK"
msgstr "正常"
msgid "Select format"
msgstr "选择格式"
msgid "No subscriptions yet."
msgstr "暂无订阅"
msgid "Delete this subscription?"
msgstr "确定删除该订阅?"
# 订阅编辑表单
msgid "Subscription URL"
msgstr "订阅 URL"
msgid "Nodes:"
msgstr "节点数:"
msgid "Last updated:"
msgstr "更新时间:"
msgid "Remaining traffic:"
msgstr "剩余流量:"
msgid "Remaining time:"
msgstr "剩余时长:"
# 节点页筛选
msgid "Protocol"
msgstr "协议"
msgid "All"
msgstr "全部"
msgid "Keyword"
msgstr "关键词"
msgid "Sort by"
msgstr "排序方式"
msgid "Desc"
msgstr "降序"
msgid "Filter"
msgstr "筛选"
msgid "Server"
msgstr "服务器"
msgid "Port"
msgstr "端口"
msgid "No nodes found."
msgstr "未找到节点"
# 节点探测结果
msgid "Node"
msgstr "节点"
msgid "Address"
msgstr "地址"
msgid "Latency"
msgstr "延迟"
msgid "URL Test"
msgstr "URL 测试"
# 组合订阅
msgid "Add combination"
msgstr "添加组合订阅"
msgid "Edit combination"
msgstr "编辑组合订阅"
msgid "Combination"
msgstr "组合"
msgid "Source subscriptions"
msgstr "来源订阅"
# 本地订阅
msgid "Add subscription link"
msgstr "添加订阅链接"
msgid "Add local subscription"
msgstr "添加本地订阅"
msgid "Edit local subscription"
msgstr "编辑本地订阅"
msgid "Subscription info"
msgstr "订阅信息"
msgid "Text import"
msgstr "文本导入"
msgid "Form import"
msgstr "表单导入"
msgid "Local"
msgstr "本地"
msgid "Add node"
msgstr "添加节点"
msgid "Paste YAML/URI/JSON node list here. Mixed protocols supported."
msgstr "在此粘贴 YAML/URI/JSON 节点列表,支持混合协议。"
msgid "Add nodes via form below. Each node will be saved as JSON and parsed on save."
msgstr "在下方表单中添加节点,保存时将序列化为 JSON 并解析。"
# 表单导入字段标签
msgid "Password"
msgstr "密码"
msgid "Cipher"
msgstr "加密"
msgid "Encryption method"
msgstr "加密方式"
msgid "Security"
msgstr "安全设置"
msgid "Network"
msgstr "传输方式"
msgid "Path"
msgstr "路径"
msgid "Obfs"
msgstr "混淆"
msgid "Obfs password"
msgstr "混淆密码"
msgid "Camouflage type"
msgstr "伪装类型"
msgid "Obfs param"
msgstr "混淆参数"
msgid "Protocol param"
msgstr "协议参数"
msgid "Skip cert verify"
msgstr "跳过证书验证"
msgid "Private key"
msgstr "私钥"
msgid "Peer public key"
msgstr "对端公钥"
msgid "Default"
msgstr "默认"
# 规则区(本地订阅表单)
msgid "Enable rules"
msgstr "启用规则"
msgid "When enabled, apply keyword include/exclude and dedup rules to nodes"
msgstr "启用后,按关键词包含/排除、去重规则处理节点"
msgid "Keyword include"
msgstr "关键词包含"
msgid "Keyword exclude"
msgstr "关键词排除"
msgid "Dedup"
msgstr "去重"
# 节点分组 / 单节点编辑
msgid "Group"
msgstr "分组"
msgid "Edit node"
msgstr "编辑节点"
msgid "Node not found."
msgstr "节点不存在或已被删除。"
msgid "Delete this node?"
msgstr "确定删除该节点?"
msgid "Failed to set group"
msgstr "分组设置失败"
msgid "Note: edits to this node are overwritten the next time the subscription is updated or re-saved."
msgstr "注意:对该节点的修改会在订阅下次更新或重新保存后被覆盖。"
+1
View File
@@ -0,0 +1 @@
zh-cn
@@ -0,0 +1,2 @@
# LuCI configuration for luci-app-substore
# 订阅元数据、定时更新与规则均已迁移至 /etc/substore/subscriptions.json(按订阅配置),此处不再使用 UCI。
@@ -0,0 +1,14 @@
#!/bin/sh
# 迁移:全局定时更新与全局规则均已废弃,改为按订阅配置
# 清理旧版遗留的全局 cron 文件与 UCI 设置
rm -f /etc/cron.d/substore
uci -q batch <<-EOF
delete substore.settings.cron_enable
delete substore.settings.cron_time
delete substore.default
commit substore
EOF
exit 0
+50
View File
@@ -0,0 +1,50 @@
#!/bin/sh
# substore cron job — 按订阅定时更新
# 用法:substore-cron.sh [订阅ID](不传则更新全部已启用订阅)
# 注意:显式设置 package.path,确保独立 cron 进程能找到 /usr/lib/lua 下的模块
DATA_DIR="/etc/substore"
LIST_FILE="$DATA_DIR/subscriptions.json"
SUB_ID="${1:-}"
LUA_BIN=""
for c in lua5.1 lua; do
if command -v "$c" >/dev/null 2>&1; then
LUA_BIN="$c"
break
fi
done
if [ -z "$LUA_BIN" ]; then
logger -t luci-app-substore "cron: no lua interpreter found"
exit 0
fi
[ -f "$LIST_FILE" ] || exit 0
export SUB_ID
LUA_PATH="/usr/lib/lua/?.lua;/usr/share/lua/?.lua;${LUA_PATH:-}" \
"$LUA_BIN" -e '
local core = require("substore.core")
local sub_id = os.getenv("SUB_ID")
local ok_count, fail_count = 0, 0
local function sync_one(id)
local ok, err = pcall(function() return core.sync(id) end)
if ok then
ok_count = ok_count + 1
else
fail_count = fail_count + 1
io.stderr:write("Failed sync " .. tostring(id) .. ": " .. tostring(err) .. "\n")
end
end
if sub_id and sub_id ~= "" then
sync_one(sub_id)
else
for _, it in ipairs(core.list()) do
if it.enabled then sync_one(it.id) end
end
end
io.stdout:write(string.format("substore cron done: %d ok, %d failed\n", ok_count, fail_count))
' 2>&1 | logger -t luci-app-substore
@@ -0,0 +1,392 @@
-- controller/admin/substore.lua — LuCI 路由注册(Lua 兼容模式)
module("luci.controller.admin.substore", package.seeall)
local function back_to_list()
local http = require("luci.http")
http.redirect(luci.dispatcher.build_url("admin", "services", "substore", "list"))
end
function index()
entry({"admin", "services", "substore"}, alias("admin", "services", "substore", "list"), nil)
entry({"admin", "services", "substore", "list"}, template("substore/subscriptions"), _("Subscriptions"), 10)
entry({"admin", "services", "substore", "form"}, template("substore/form"), nil)
entry({"admin", "services", "substore", "localform"}, template("substore/local_form"), nil)
entry({"admin", "services", "substore", "nodes"}, template("substore/nodes"), nil)
entry({"admin", "services", "substore", "output"}, template("substore/output"), nil)
entry({"admin", "services", "substore", "combo"}, template("substore/combo"), nil)
entry({"admin", "services", "substore", "create"}, call("action_create"), nil)
entry({"admin", "services", "substore", "save"}, call("action_save"), nil)
entry({"admin", "services", "substore", "local_create"}, call("action_local_create"), nil)
entry({"admin", "services", "substore", "local_save"}, call("action_local_save"), nil)
entry({"admin", "services", "substore", "combo_save"}, call("action_combo_save"), nil)
entry({"admin", "services", "substore", "node_edit"}, template("substore/node_edit"), nil)
entry({"admin", "services", "substore", "node_save"}, call("action_node_save"), nil)
entry({"admin", "services", "substore", "node_delete"}, call("action_node_delete"), nil)
entry({"admin", "services", "substore", "node_set_group"}, call("action_node_set_group"), nil)
entry({"admin", "services", "substore", "delete"}, call("action_delete"), nil)
entry({"admin", "services", "substore", "update"}, call("action_update"), nil)
entry({"admin", "services", "substore", "probe"}, call("action_probe"), nil)
-- public download endpoint token based, no login, for Passwall OpenClash
entry({"substore", "download"}, call("action_download"), nil)
end
local function post_ok()
-- 拒绝缺失 CSRF token 的 POST
local http = require("luci.http")
return http.formvalue("token") ~= nil
end
-- read cron fields from form, validate cron expression, return cron_enable cron_time
local function read_cron_fields()
local http = require("luci.http")
local core = require("substore.core")
local cron_enable = http.formvalue("cron_enable") or "0"
local m = http.formvalue("cron_min") or "0"
local h = http.formvalue("cron_hour") or "3"
local dom = http.formvalue("cron_dom") or "*"
local mon = http.formvalue("cron_mon") or "*"
local dow = http.formvalue("cron_dow") or "*"
local cron_time = table.concat({m,h,dom,mon,dow}, " ")
if not core.cron_time_valid(cron_time) then
cron_enable = "0"
cron_time = ""
end
if cron_enable ~= "1" then cron_enable = "0" end
return cron_enable, cron_time
end
-- read subscription level rules from form, return rules table
local function read_rules_fields()
local http = require("luci.http")
local proto_list = {}
for _, p in ipairs({"vmess","vless","trojan","shadowsocks","ssr","hysteria2","tuic","hysteria","wireguard","socks"}) do
if http.formvalue("proto_filter_"..p) then proto_list[#proto_list+1]=p end
end
local rules_enable = http.formvalue("rules_enable") or "0"
if rules_enable ~= "1" then rules_enable = "0" end
return {
rules_enable = rules_enable,
proto_filter = table.concat(proto_list, ","),
keyword_include = http.formvalue("keyword_include") or "",
keyword_exclude = http.formvalue("keyword_exclude") or "",
dedup = http.formvalue("dedup") or "0",
rename_map = http.formvalue("rename_map") or "",
}
end
function action_create()
local http = require("luci.http")
local core = require("substore.core")
if post_ok() then
local name = (http.formvalue("name") or ""):gsub("^%s+", ""):gsub("%s+$", "")
local url = (http.formvalue("url") or ""):gsub("^%s+", ""):gsub("%s+$", "")
local proxy_enable = http.formvalue("proxy_enable") or "0"
if proxy_enable ~= "1" then proxy_enable = "0" end
local proxy = (http.formvalue("proxy") or ""):gsub("^%s+", ""):gsub("%s+$", "")
if name ~= "" and url ~= "" then
local cron_enable, cron_time = read_cron_fields()
local rules = read_rules_fields()
core.add(name, url, {
proxy_enable = proxy_enable, proxy = proxy,
cron_enable = cron_enable, cron_time = cron_time,
rules_enable = rules.rules_enable, proto_filter = rules.proto_filter,
keyword_include = rules.keyword_include, keyword_exclude = rules.keyword_exclude,
dedup = rules.dedup, rename_map = rules.rename_map,
})
core.write_cron()
end
end
back_to_list()
end
function action_save()
local http = require("luci.http")
local core = require("substore.core")
if post_ok() then
local id = http.formvalue("id") or ""
local name = (http.formvalue("name") or ""):gsub("^%s+", ""):gsub("%s+$", "")
local url = (http.formvalue("url") or ""):gsub("^%s+", ""):gsub("%s+$", "")
local proxy_enable = http.formvalue("proxy_enable") or "0"
if proxy_enable ~= "1" then proxy_enable = "0" end
local proxy = (http.formvalue("proxy") or ""):gsub("^%s+", ""):gsub("%s+$", "")
if name ~= "" and url ~= "" then
local cron_enable, cron_time = read_cron_fields()
local rules = read_rules_fields()
core.save_meta(id, {
name = name, url = url, proxy_enable = proxy_enable, proxy = proxy,
cron_enable = cron_enable, cron_time = cron_time,
rules_enable = rules.rules_enable, proto_filter = rules.proto_filter,
keyword_include = rules.keyword_include, keyword_exclude = rules.keyword_exclude,
dedup = rules.dedup, rename_map = rules.rename_map,
})
core.write_cron()
end
end
back_to_list()
end
function action_local_create()
local http = require("luci.http")
local core = require("substore.core")
if post_ok() then
local name = (http.formvalue("name") or ""):gsub("^%s+", ""):gsub("%s+$", "")
local content = http.formvalue("content") or ""
local local_mode = http.formvalue("local_mode") or "text"
local rules = read_rules_fields()
if name ~= "" and content ~= "" then
core.add_local(name, content, local_mode, {
rules_enable = rules.rules_enable,
proto_filter = rules.proto_filter,
keyword_include = rules.keyword_include,
keyword_exclude = rules.keyword_exclude,
dedup = rules.dedup,
})
end
end
back_to_list()
end
function action_local_save()
local http = require("luci.http")
local core = require("substore.core")
if post_ok() then
local id = http.formvalue("id") or ""
local name = (http.formvalue("name") or ""):gsub("^%s+", ""):gsub("%s+$", "")
local content = http.formvalue("content") or ""
local local_mode = http.formvalue("local_mode") or "text"
local rules = read_rules_fields()
if id ~= "" and name ~= "" and content ~= "" then
core.save_meta(id, {
name = name,
raw_content = content,
local_mode = local_mode,
rules_enable = rules.rules_enable,
proto_filter = rules.proto_filter,
keyword_include = rules.keyword_include,
keyword_exclude = rules.keyword_exclude,
dedup = rules.dedup,
})
core.sync(id)
end
end
back_to_list()
end
function action_delete()
local http = require("luci.http")
local core = require("substore.core")
if post_ok() then
core.remove(http.formvalue("id") or "")
core.write_cron()
end
back_to_list()
end
-- 节点页返回链接:保留当前筛选参数
local function back_to_nodes(http)
local id = http.formvalue("id") or ""
local qs = "?id=" .. luci.util.urlencode(id)
for _, k in ipairs({ "proto", "keyword", "group", "sort", "desc" }) do
local v = http.formvalue(k)
if v and v ~= "" then qs = qs .. "&" .. k .. "=" .. luci.util.urlencode(v) end
end
http.redirect(luci.dispatcher.build_url("admin", "services", "substore", "nodes") .. qs)
end
-- 单节点保存:表单 JSON 解析后经 merge_form_node 合并到原节点(保留 raw/tags 等非表单字段)
function action_node_save()
local http = require("luci.http")
local core = require("substore.core")
local parser = require("substore.parser")
if post_ok() then
local id = http.formvalue("id") or ""
local idx = tonumber(http.formvalue("idx") or "")
local content = http.formvalue("content") or ""
local nodes = core.read_nodes(id)
if idx and nodes[idx] and content ~= "" then
local res = parser.parse_local(content, "form")
local newn = res and res.nodes and res.nodes[1]
if newn then
nodes[idx] = core.merge_form_node(nodes[idx], newn)
if core.write_nodes(id, nodes) then
core.refresh_combos(id)
end
end
end
end
back_to_nodes(http)
end
-- 单节点删除
function action_node_delete()
local http = require("luci.http")
local core = require("substore.core")
if post_ok() then
local id = http.formvalue("id") or ""
local idx = tonumber(http.formvalue("idx") or "")
local nodes = core.read_nodes(id)
if idx and nodes[idx] then
table.remove(nodes, idx)
if core.write_nodes(id, nodes) then
core.save_meta(id, { node_count = #nodes })
core.refresh_combos(id)
end
end
end
back_to_nodes(http)
end
-- 单节点分组快速设置(XHR,JSON 响应)
function action_node_set_group()
local http = require("luci.http")
local core = require("substore.core")
local util = require("substore.util")
http.prepare_content("application/json")
if not post_ok() then
http.write(util.json_encode({ ok = false, err = "forbidden" }))
return
end
local id = http.formvalue("id") or ""
local idx = tonumber(http.formvalue("idx") or "")
local group = util.trim(http.formvalue("group") or "")
local nodes = core.read_nodes(id)
if not idx or not nodes[idx] then
http.write(util.json_encode({ ok = false, err = "node not found" }))
return
end
nodes[idx].group = group ~= "" and group or nil
if not core.write_nodes(id, nodes) then
http.write(util.json_encode({ ok = false, err = "write failed" }))
return
end
http.write(util.json_encode({ ok = true }))
end
-- combo save: id empty create else edit, sources from src_id checkboxes and reuse rules
function action_combo_save()
local http = require("luci.http")
local core = require("substore.core")
local util = require("substore.util")
if post_ok() then
local id = util.trim(http.formvalue("id") or "")
local name = (http.formvalue("name") or ""):gsub("^%s+", ""):gsub("%s+$", "")
local sources = {}
for _, it in ipairs(core.list()) do
if not it.combo and http.formvalue("src_" .. it.id) then
sources[#sources + 1] = it.id
end
end
if name ~= "" then
local rules = read_rules_fields()
local o = {
rules_enable = rules.rules_enable, proto_filter = rules.proto_filter,
keyword_include = rules.keyword_include, keyword_exclude = rules.keyword_exclude,
dedup = rules.dedup,
}
if id == "" then
core.add_combo(name, sources, o)
else
core.save_combo(id, name, sources, o)
end
end
end
back_to_list()
end
function action_update()
local http = require("luci.http")
local core = require("substore.core")
if post_ok() then
local id = http.formvalue("id") or ""
local meta = core.get(id)
if meta and meta["local"] then
-- local subscription does not support auto update
return
end
-- pcall fallback: parse or write error will not 500 the page, error saved to list status
local ok, err = pcall(function()
return core.sync(id)
end)
if not ok then
core.save_meta(id, { error = tostring(err), last_update = os.time() })
end
end
back_to_list()
end
-- node probe endpoint: POST id + mode ping tcping url + proto + keyword, return JSON
function action_probe()
local http = require("luci.http")
local core = require("substore.core")
local node = require("substore.node")
local util = require("substore.util")
local probe = require("substore.probe")
if not post_ok() then
http.status(403, "Forbidden")
http.prepare_content("text/plain; charset=utf-8")
http.write("invalid token")
return
end
local id = util.trim(http.formvalue("id") or "")
local mode = util.trim(http.formvalue("mode") or "")
if mode ~= "ping" and mode ~= "tcping" and mode ~= "url" then
http.status(400, "Bad Request")
http.prepare_content("text/plain; charset=utf-8")
http.write("bad mode")
return
end
local meta = core.get(id)
if not meta then
http.status(404, "Not Found")
http.prepare_content("text/plain; charset=utf-8")
http.write("not found")
return
end
-- 与 nodes 页一致的过滤:按当前 proto / keyword 过滤后逐个探测
local nodes = core.read_nodes(id)
local proto = util.trim(http.formvalue("proto") or "")
local keyword = util.trim(http.formvalue("keyword") or "")
if proto ~= "" then nodes = node.filter(nodes, { proto = proto }) end
if keyword ~= "" then nodes = node.filter(nodes, { keyword = keyword }) end
nodes = node.sort(nodes, "name", false)
local results = probe.probe(nodes, mode)
local ok_count, sum = 0, 0
for _, r in ipairs(results) do
if r.latency then
ok_count = ok_count + 1
sum = sum + r.latency
end
end
local avg = ok_count > 0 and math.floor(sum / ok_count + 0.5) or nil
http.prepare_content("application/json; charset=utf-8")
http.write(util.json_encode({
ok = true, mode = mode, total = #results,
ok_count = ok_count, avg = avg, results = results,
}))
end
-- 公开下载端点:GET /substore/download?token=<token>&target=<format>
function action_download()
local http = require("luci.http")
local core = require("substore.core")
local util = require("substore.util")
local token = util.trim(http.formvalue("token") or "")
local target = util.trim(http.formvalue("target") or "ClashMeta")
local content, ct, filename, err = core.generate_link(token, target)
if not content then
http.status(404, "Not Found")
http.prepare_content("text/plain; charset=utf-8")
http.write(err or "not found")
return
end
http.prepare_content(ct)
-- 文件名白名单:剥离引号/换行/控制字符,防 HTTP 头注入
local safe_name = (filename or "subscription.txt"):gsub("[^%w%-%._]", "_")
http.header("Content-Disposition",
"attachment; filename=\"" .. safe_name .. "\"")
http.write(content)
end
@@ -0,0 +1,80 @@
<%+header%>
<%
local http = require("luci.http")
local core = require("substore.core")
local id = http.formvalue("id") or ""
local it = id ~= "" and core.get(id) or nil
local is_edit = it ~= nil
local rules_enabled = it and (it.rules_enable == true or it.rules_enable == "1") or false
local ki = (it and it.keyword_include) or ""
local ke = (it and it.keyword_exclude) or ""
local dedup = it and (it.dedup == "1" or it.dedup == true)
local sel = {}
if it and type(it.sources) == "table" then
for _, s in ipairs(it.sources) do sel[s] = true end
end
local srcs = {}
for _, x in ipairs(core.list()) do
if not x.combo then srcs[#srcs + 1] = x end
end
%>
<h2><%=is_edit and luci.i18n.translate("Edit combination") or luci.i18n.translate("Add combination")%></h2>
<form method="post" action="<%=luci.dispatcher.build_url('admin/services/substore/combo_save')%>" class="cbi-map">
<input type="hidden" name="token" value="<%=token%>"/>
<% if is_edit then %><input type="hidden" name="id" value="<%=it.id%>"/><% end %>
<div class="cbi-section">
<div class="cbi-section-descr" style="margin:0.5em 0">
<label for="name" style="display:inline-block;min-width:10em"><%:Name%></label>
<input type="text" id="name" name="name" value="<%=is_edit and it.name or ''%>"/>
</div>
<div class="cbi-section-descr" style="margin:0.5em 0">
<label style="display:inline-block;min-width:10em;vertical-align:top"><%:Source subscriptions%></label>
<div style="display:inline-block">
<% for _, x in ipairs(srcs) do %>
<div style="margin:0.25em 0">
<input type="checkbox" id="src_<%=x.id%>" name="src_<%=x.id%>" value="1" <%=sel[x.id] and "checked" or ""%>/>
<label for="src_<%=x.id%>"><%=x.name%>(<%=x.node_count or 0%>)</label>
</div>
<% end %>
<% if #srcs == 0 then %>
<span style="color:#808080"><%:No subscriptions yet.%></span>
<% end %>
</div>
</div>
<div class="cbi-section-descr" style="margin:0.5em 0">
<label style="display:inline-block;min-width:10em">启用规则</label>
<input type="checkbox" name="rules_enable" value="1" <%=rules_enabled and "checked" or ""%>
onchange="var r=document.getElementById('rules_row');if(r)r.style.display=this.checked?'block':'none';"/>
<span class="cbi-value-description">按关键词包含 / 排除、去重规则筛选合并后的节点</span>
</div>
<div id="rules_row" style="margin:0.5em 0;display:<%=rules_enabled and "block" or "none"%>">
<div class="cbi-section-descr" style="margin:0.25em 0">
<label style="display:inline-block;min-width:10em">关键词包含</label>
<input type="text" name="keyword_include" value="<%=ki%>" style="width:60%"/>
</div>
<div class="cbi-section-descr" style="margin:0.25em 0">
<label style="display:inline-block;min-width:10em">关键词排除</label>
<input type="text" name="keyword_exclude" value="<%=ke%>" style="width:60%"/>
</div>
<div class="cbi-section-descr" style="margin:0.25em 0">
<label style="display:inline-block;min-width:10em">去重</label>
<input type="checkbox" name="dedup" value="1" <%=dedup and "checked" or ""%>/>
</div>
<div class="cbi-section-descr" style="margin:0.25em 0">
<span style="color:#808080">多条关键词用逗号分隔(如:香港,美国,日本);包含 = 命中任一即保留,排除 = 命中任一即去除。</span>
</div>
</div>
<% if is_edit then %>
<div class="cbi-section-descr" style="margin:0.5em 0">
<span style="color:#808080"><%:Nodes:%> <%=it.node_count or 0%> | <%:Last updated:%> <%=it.last_update and os.date("%Y-%m-%d %H:%M", it.last_update) or "-"%></span>
</div>
<% end %>
<div class="cbi-page-actions">
<input type="submit" class="cbi-button cbi-button-apply" value="<%:Save%>"/>
<a class="cbi-button cbi-button-reset" href="<%=luci.dispatcher.build_url('admin/services/substore/list')%>"><%:Back%></a>
</div>
</div>
</form>
<%+footer%>
@@ -0,0 +1,112 @@
<%+header%>
<%
local http = require("luci.http")
local core = require("substore.core")
local id = http.formvalue("id") or ""
local it = id ~= "" and core.get(id) or nil
local is_edit = it ~= nil
local cron_enabled = it and (it.cron_enable == true or it.cron_enable == "1") or false
local cron = (it and it.cron_time and it.cron_time ~= "") and it.cron_time or "0 3 * * *"
local cm, ch, cdom, cmon, cdow = cron:match("^(%S+) (%S+) (%S+) (%S+) (%S+)$")
cm = cm or "0"; ch = ch or "3"; cdom = cdom or "*"; cmon = cmon or "*"; cdow = cdow or "*"
local rules_enabled = it and (it.rules_enable == true or it.rules_enable == "1") or false
local ki = (it and it.keyword_include) or ""
local ke = (it and it.keyword_exclude) or ""
local dedup = it and (it.dedup == "1" or it.dedup == true)
local proxy_enabled = it and (it.proxy_enable == true or it.proxy_enable == "1") or false
local util = require("substore.util")
-- 剩余流量 / 剩余时长(仅编辑页且有 subscription-userinfo 数据时显示,首页不显示)
local traffic_str = nil
local time_str = nil
if is_edit and it then
if it.total then
local rem = (tonumber(it.total) or 0) - (tonumber(it.upload) or 0) - (tonumber(it.download) or 0)
if rem < 0 then rem = 0 end
traffic_str = util.human_bytes(rem)
end
if it.expire then
time_str = util.human_duration((tonumber(it.expire) or 0) - os.time())
end
end
%>
<h2><%=is_edit and luci.i18n.translate("Edit subscription") or luci.i18n.translate("Add subscription")%></h2>
<form method="post" action="<%=luci.dispatcher.build_url('admin/services/substore', is_edit and 'save' or 'create')%>" class="cbi-map">
<input type="hidden" name="token" value="<%=token%>"/>
<% if is_edit then %><input type="hidden" name="id" value="<%=it.id%>"/><% end %>
<div class="cbi-section">
<div class="cbi-section-descr" style="margin:0.5em 0">
<label for="name" style="display:inline-block;min-width:10em"><%:Name%></label>
<input type="text" id="name" name="name" value="<%=is_edit and it.name or ''%>"/>
</div>
<div class="cbi-section-descr" style="margin:0.5em 0">
<label for="url" style="display:inline-block;min-width:10em"><%:Subscription URL%></label>
<input type="text" id="url" name="url" value="<%=is_edit and it.url or ''%>" style="width:60%"/>
</div>
<div class="cbi-section-descr" style="margin:0.5em 0">
<label style="display:inline-block;min-width:10em">订阅代理</label>
<input type="checkbox" name="proxy_enable" value="1" <%=proxy_enabled and "checked" or ""%>
onchange="var r=document.getElementById('proxy_row');if(r)r.style.display=this.checked?'block':'none';"/>
</div>
<div id="proxy_row" class="cbi-section-descr" style="margin:0.5em 0;display:<%=proxy_enabled and "block" or "none"%>">
<label for="proxy" style="display:inline-block;min-width:10em">代理地址</label>
<input type="text" id="proxy" name="proxy" value="<%=is_edit and it.proxy or ''%>" style="width:60%"/>
<div style="color:#808080;margin-top:0.25em">通过代理下载订阅(留空则不使用),支持 http:// 或 socks5:// 地址,如 socks5://192.168.5.1:1080</div>
</div>
<div class="cbi-section-descr" style="margin:0.5em 0">
<label style="display:inline-block;min-width:10em">启用定时更新</label>
<input type="checkbox" name="cron_enable" value="1" <%=cron_enabled and "checked" or ""%>
onchange="var r=document.getElementById('cron_time_row');if(r)r.style.display=this.checked?'block':'none';"/>
</div>
<div id="cron_time_row" class="cbi-section-descr" style="margin:0.5em 0;display:<%=cron_enabled and "block" or "none"%>">
<label style="display:inline-block;min-width:10em;vertical-align:middle">定时更新时间</label>
<span style="display:inline-flex;flex-wrap:wrap;gap:0.5em;vertical-align:middle">
<select name="cron_min"><% for i=0,59 do %><option value="<%=i%>" <%=tonumber(cm)==i and "selected" or ""%>><%=i%></option><% end %></select> 分钟
<select name="cron_hour"><% for i=0,23 do %><option value="<%=i%>" <%=tonumber(ch)==i and "selected" or ""%>><%=i%></option><% end %></select> 小时
<select name="cron_dom"><% for _,v in ipairs({"*","1","2","3","4","5","6","7","8","9","10","11","12","13","14","15","16","17","18","19","20","21","22","23","24","25","26","27","28","29","30","31"}) do %><option value="<%=v%>" <%=cdom==v and "selected" or ""%>><%=v%></option><% end %></select> 日
<select name="cron_mon"><% for _,v in ipairs({"*","1","2","3","4","5","6","7","8","9","10","11","12"}) do %><option value="<%=v%>" <%=cmon==v and "selected" or ""%>><%=v%></option><% end %></select> 月
<select name="cron_dow"><% for _,v in ipairs({"*","0","1","2","3","4","5","6"}) do %><option value="<%=v%>" <%=cdow==v and "selected" or ""%>><%=v%></option><% end %></select> 周
</span>
</div>
<div class="cbi-section-descr" style="margin:0.5em 0">
<label style="display:inline-block;min-width:10em">启用规则</label>
<input type="checkbox" name="rules_enable" value="1" <%=rules_enabled and "checked" or ""%>
onchange="var r=document.getElementById('rules_row');if(r)r.style.display=this.checked?'block':'none';"/>
<span class="cbi-value-description">启用后,更新订阅时按关键词包含/排除、去重规则处理节点</span>
</div>
<div id="rules_row" style="margin:0.5em 0;display:<%=rules_enabled and "block" or "none"%>">
<div class="cbi-section-descr" style="margin:0.25em 0">
<label style="display:inline-block;min-width:10em">关键词包含</label>
<input type="text" name="keyword_include" value="<%=ki%>" style="width:60%"/>
</div>
<div class="cbi-section-descr" style="margin:0.25em 0">
<label style="display:inline-block;min-width:10em">关键词排除</label>
<input type="text" name="keyword_exclude" value="<%=ke%>" style="width:60%"/>
</div>
<div class="cbi-section-descr" style="margin:0.25em 0">
<label style="display:inline-block;min-width:10em">去重</label>
<input type="checkbox" name="dedup" value="1" <%=dedup and "checked" or ""%>/>
</div>
<div class="cbi-section-descr" style="margin:0.25em 0">
<span style="color:#808080">多条关键词用逗号分隔(如:香港,美国,日本);包含 = 命中任一即保留,排除 = 命中任一即去除。</span>
</div>
</div>
<% if is_edit and it.last_update then %>
<div class="cbi-section-descr" style="margin:0.5em 0">
<span style="color:#808080">
<%:Nodes:%> <%=it.node_count or 0%>
<% 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"><%=it.error%></span><% end %>
</span>
</div>
<% end %>
<div class="cbi-page-actions">
<input type="submit" class="cbi-button cbi-button-apply" value="<%:Save%>"/>
<a class="cbi-button cbi-button-reset" href="<%=luci.dispatcher.build_url('admin/services/substore/list')%>"><%:Back%></a>
</div>
</div>
</form>
<%+footer%>
@@ -0,0 +1,172 @@
<%+header%>
<%
local http = require("luci.http")
local core = require("substore.core")
local id = http.formvalue("id") or ""
local it = id ~= "" and core.get(id) or nil
local is_edit = it ~= nil
local rules_enabled = it and (it.rules_enable == true or it.rules_enable == "1") or false
local ki = (it and it.keyword_include) or ""
local ke = (it and it.keyword_exclude) or ""
local dedup = it and (it.dedup == "1" or it.dedup == true)
local local_mode = (it and it.local_mode) or "text"
local content = (it and it.raw_content) or ""
-- 注入 JS 的初始内容:JSON 字符串字面量,转义 "</" 防止提前闭合 script
local util = require("substore.util")
local js_content = (util.json_encode(content) or '""'):gsub("</", "<\\/")
%>
<h2><%=is_edit and luci.i18n.translate("Edit local subscription") or luci.i18n.translate("Add local subscription")%></h2>
<form method="post" action="<%=luci.dispatcher.build_url('admin/services/substore', is_edit and 'local_save' or 'local_create')%>" class="cbi-map"
onsubmit="return substore_local_prepare()">
<input type="hidden" name="token" value="<%=token%>"/>
<% if is_edit then %><input type="hidden" name="id" value="<%=it.id%>"/><% end %>
<div class="cbi-section">
<div class="cbi-section-descr" style="margin:0.5em 0">
<label for="name" style="display:inline-block;min-width:10em"><%:Name%></label>
<input type="text" id="name" name="name" value="<%=is_edit and it.name or ''%>"/>
</div>
<div class="cbi-section-descr" style="margin:0.5em 0">
<label style="display:inline-block;min-width:10em;vertical-align:top"><%:Subscription info%></label>
<div style="display:inline-block;width:80%">
<div style="margin-bottom:0.5em">
<select name="local_mode" id="local_mode_sel" onchange="substore_local_switch(this.value)">
<option value="text" <%=local_mode=="text" and "selected" or ""%>><%:Text import%></option>
<option value="form" <%=local_mode=="form" and "selected" or ""%>><%:Form import%></option>
</select>
</div>
<div id="text_area_wrap" style="display:<%=local_mode=="text" and "block" or "none"%>">
<textarea id="content_text" name="content" rows="16" style="width:100%"
<%=local_mode=="text" and "" or "disabled"%>><%=luci.util.pcdata(content)%></textarea>
<div style="color:#808080;margin-top:0.25em"><%:Paste YAML/URI/JSON node list here. Mixed protocols supported.%></div>
</div>
<div id="form_nodes_container" style="display:<%=local_mode=="form" and "block" or "none"%>">
<div style="margin-bottom:0.5em;color:#808080"><%:Add nodes via form below. Each node will be saved as JSON and parsed on save.%></div>
<div id="nodes_list"></div>
<button type="button" class="cbi-button cbi-button-apply" onclick="addNodeRow()">+ <%:Add node%></button>
<input type="hidden" name="content" id="form_content" value=""
<%=local_mode=="form" and "" or "disabled"%>/>
</div>
</div>
</div>
<div class="cbi-section-descr" style="margin:0.5em 0">
<label style="display:inline-block;min-width:10em"><%:Enable rules%></label>
<input type="checkbox" name="rules_enable" value="1" <%=rules_enabled and "checked" or ""%>
onchange="var r=document.getElementById('rules_row');if(r)r.style.display=this.checked?'block':'none';"/>
<span class="cbi-value-description"><%:When enabled, apply keyword include/exclude and dedup rules to nodes%></span>
</div>
<div id="rules_row" style="margin:0.5em 0;display:<%=rules_enabled and "block" or "none"%>">
<div class="cbi-section-descr" style="margin:0.25em 0">
<label style="display:inline-block;min-width:10em"><%:Keyword include%></label>
<input type="text" name="keyword_include" value="<%=ki%>" style="width:60%"/>
</div>
<div class="cbi-section-descr" style="margin:0.25em 0">
<label style="display:inline-block;min-width:10em"><%:Keyword exclude%></label>
<input type="text" name="keyword_exclude" value="<%=ke%>" style="width:60%"/>
</div>
<div class="cbi-section-descr" style="margin:0.25em 0">
<label style="display:inline-block;min-width:10em"><%:Dedup%></label>
<input type="checkbox" name="dedup" value="1" <%=dedup and "checked" or ""%>/>
</div>
</div>
<% if is_edit and it.last_update then %>
<div class="cbi-section-descr" style="margin:0.5em 0">
<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"><%=it.error%></span><% end %>
</span>
</div>
<% end %>
<div class="cbi-page-actions">
<input type="submit" class="cbi-button cbi-button-apply" value="<%:Save%>"/>
<a class="cbi-button cbi-button-reset" href="<%=luci.dispatcher.build_url('admin/services/substore/list')%>"><%:Back%></a>
</div>
</div>
</form>
<script>
// 编辑时的初始内容(文本或 JSON),由服务端注入
var SUBSTORE_INIT_CONTENT = <%=js_content%>;
var SUBSTORE_INIT_MODE = "<%=local_mode%>";
// 字段标签与界面文本(nodeform.js 读取;翻译由服务端按系统语言渲染)
var FIELD_LABELS = {
"name": "<%:Name%>",
"group": "<%:Group%>",
"protocol": "<%:Protocol%>",
"server": "<%:Server%>",
"port": "<%:Port%>",
"password": "<%:Password%>",
"uuid": "UUID",
"alterId": "Alter ID",
"cipher": "<%:Cipher%>",
"method": "<%:Encryption method%>",
"security": "<%:Security%>",
"net": "<%:Network%>",
"headerType": "<%:Camouflage type%>",
"flow": "Flow",
"path": "<%:Path%>",
"host": "Host",
"sni": "SNI",
"tls": "TLS",
"obfs": "<%:Obfs%>",
"obfs-param": "<%:Obfs param%>",
"obfs-password": "<%:Obfs password%>",
"protocol-param": "<%:Protocol param%>",
"udp": "UDP",
"skip-cert-verify": "<%:Skip cert verify%>",
"private-key": "<%:Private key%>",
"peer-public-key": "<%:Peer public key%>"
};
var NODEFORM_I18N = {
"node": "<%:Node%>",
"defaultOpt": "<%:Default%>"
};
</script>
<script src="/luci-static/resources/substore/nodeform.js?v=2.1.3"></script>
<script>
// 切换导入模式:仅切换显示/禁用,不提交表单
function substore_local_switch(mode) {
document.getElementById("text_area_wrap").style.display = (mode === "text") ? "block" : "none";
document.getElementById("form_nodes_container").style.display = (mode === "form") ? "block" : "none";
document.getElementById("content_text").disabled = (mode !== "text");
document.getElementById("form_content").disabled = (mode !== "form");
if (mode === "form" && document.getElementById("nodes_list").children.length === 0) addNodeRow();
}
// 提交前:表单模式把节点序列化为 JSON 写入隐藏 content 字段
function substore_local_prepare() {
var mode = document.getElementById("local_mode_sel").value;
if (mode === "form") {
var hidden = document.getElementById("form_content");
hidden.disabled = false;
hidden.value = JSON.stringify(collectNodes());
document.getElementById("content_text").disabled = true;
}
return true;
}
function initLocalForm() {
if (SUBSTORE_INIT_MODE !== "form") return;
var data = null;
try {
data = JSON.parse(SUBSTORE_INIT_CONTENT || "[]");
} catch (e) {
data = null;
}
if (data && Object.prototype.toString.call(data) === "[object Array]" && data.length > 0) {
for (var i = 0; i < data.length; i++) addNodeRow(data[i]);
} else {
addNodeRow();
}
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initLocalForm);
} else {
initLocalForm();
}
</script>
<%+footer%>
@@ -0,0 +1,99 @@
<%+header%>
<%
local http = require("luci.http")
local core = require("substore.core")
local util = require("substore.util")
local id = http.formvalue("id") or ""
local idx = tonumber(http.formvalue("idx") or "") or 0
local meta = id ~= "" and core.get(id) or nil
local nodes = id ~= "" and core.read_nodes(id) or {}
local n = nodes[idx]
-- 注入 JS 的节点对象:转义 "</" 防止提前闭合 script
local js_node = n and (util.json_encode(n):gsub("</", "<\\/")) or "null"
local back_url = luci.dispatcher.build_url('admin/services/substore/nodes') .. "?id=" .. luci.util.urlencode(id)
%>
<h2><%:Edit node%> — <%=meta and meta.name or ""%></h2>
<% if not n then %>
<div class="cbi-section">
<p style="color:#cc0000"><%:Node not found.%></p>
<a class="cbi-button cbi-button-reset" href="<%=back_url%>"><%:Back%></a>
</div>
<% else %>
<form method="post" action="<%=luci.dispatcher.build_url('admin/services/substore/node_save')%>" class="cbi-map"
onsubmit="return node_edit_prepare()">
<input type="hidden" name="token" value="<%=token%>"/>
<input type="hidden" name="id" value="<%=id%>"/>
<input type="hidden" name="idx" value="<%=idx%>"/>
<div class="cbi-section">
<div id="nodes_list"></div>
<input type="hidden" name="content" id="form_content" value=""/>
<div class="cbi-section-descr" style="margin:0.5em 0;color:#808080">
<%:Note: edits to this node are overwritten the next time the subscription is updated or re-saved.%>
</div>
<div class="cbi-page-actions">
<input type="submit" class="cbi-button cbi-button-apply" value="<%:Save%>"/>
<a class="cbi-button cbi-button-reset" href="<%=back_url%>"><%:Back%></a>
</div>
</div>
</form>
<script>
var FIELD_LABELS = {
"name": "<%:Name%>",
"group": "<%:Group%>",
"protocol": "<%:Protocol%>",
"server": "<%:Server%>",
"port": "<%:Port%>",
"password": "<%:Password%>",
"uuid": "UUID",
"alterId": "Alter ID",
"cipher": "<%:Cipher%>",
"method": "<%:Encryption method%>",
"security": "<%:Security%>",
"net": "<%:Network%>",
"headerType": "<%:Camouflage type%>",
"flow": "Flow",
"path": "<%:Path%>",
"host": "Host",
"sni": "SNI",
"tls": "TLS",
"obfs": "<%:Obfs%>",
"obfs-param": "<%:Obfs param%>",
"obfs-password": "<%:Obfs password%>",
"protocol-param": "<%:Protocol param%>",
"udp": "UDP",
"skip-cert-verify": "<%:Skip cert verify%>",
"private-key": "<%:Private key%>",
"peer-public-key": "<%:Peer public key%>"
};
var NODEFORM_I18N = {
"node": "<%:Node%>",
"defaultOpt": "<%:Default%>"
};
var NODE_EDIT_DATA = <%=js_node%>;
</script>
<script src="/luci-static/resources/substore/nodeform.js?v=2.1.3"></script>
<script>
function node_edit_prepare() {
var arr = collectNodes();
if (arr.length === 0) return false;
document.getElementById("form_content").value = JSON.stringify(arr);
return true;
}
function initNodeEdit() {
var row = addNodeRow(NODE_EDIT_DATA);
// 编辑页只有一个节点行,不允许移除
var rm = row && row.querySelector(".node_remove");
if (rm) rm.style.display = "none";
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initNodeEdit);
} else {
initNodeEdit();
}
</script>
<% end %>
<%+footer%>
@@ -0,0 +1,203 @@
<%+header%>
<%
local http = require("luci.http")
local core = require("substore.core")
local node = require("substore.node")
local ctx = luci.dispatcher.context or {}
local q = ctx.query or {}
local id = http.formvalue("id") or q.id or ""
local meta = id ~= "" and core.get(id) or nil
local nodes = id ~= "" and core.read_nodes(id) or {}
local proto = http.formvalue("proto") or q.proto or ""
local keyword = http.formvalue("keyword") or q.keyword or ""
local group = http.formvalue("group") or q.group or ""
local sort_by = http.formvalue("sort") or q.sort or "name"
local sort_desc = http.formvalue("desc") == "1" or q.desc == "1"
-- 标注原始数组下标(filter 保留表引用、sort 原地排序,下标可穿透筛选排序)
for i, nn in ipairs(nodes) do nn.__idx = i end
-- 分组下拉选项:当前订阅节点的去重分组集合
local group_set, group_list = {}, {}
for _, nn in ipairs(nodes) do
local g = nn.group
if g and g ~= "" and not group_set[g] then
group_set[g] = true
group_list[#group_list + 1] = g
end
end
table.sort(group_list)
if proto ~= "" then nodes = node.filter(nodes, {proto = proto}) end
if group ~= "" then nodes = node.filter(nodes, {group = group}) end
if keyword ~= "" then nodes = node.filter(nodes, {keyword = keyword}) end
nodes = node.sort(nodes, sort_by, sort_desc)
local node_edit_base = luci.dispatcher.build_url('admin/services/substore/node_edit')
%>
<h2><%:Nodes%> — <%=meta and meta.name or ""%></h2>
<div style="margin:0.5em 0">
<a class="cbi-button cbi-button-reset" href="<%=luci.dispatcher.build_url('admin/services/substore/list')%>"><%:Back%></a>
</div>
<form method="get" action="<%=luci.dispatcher.build_url('admin/services/substore/nodes')%>" class="cbi-map">
<input type="hidden" name="id" value="<%=id%>"/>
<div style="display:flex;gap:0.5em;align-items:center;flex-wrap:wrap;margin:0.5em 0">
<label><%:Protocol%>:
<select name="proto">
<option value=""><%:All%></option>
<% for _, p in ipairs({"vmess","vless","trojan","shadowsocks","ssr","hysteria2","tuic","hysteria","wireguard","socks"}) do %>
<option value="<%=p%>" <%=proto==p and "selected" or ""%>><%=p%></option>
<% end %>
</select>
</label>
<label><%:Group%>:
<select name="group">
<option value=""><%:All%></option>
<% for _, g in ipairs(group_list) do %>
<option value="<%=luci.util.pcdata(g)%>" <%=group==g and "selected" or ""%>><%=g%></option>
<% end %>
</select>
</label>
<label><%:Keyword%>: <input type="text" name="keyword" value="<%=keyword%>"/></label>
<label><%:Sort by%>:
<select name="sort">
<option value="name" <%=sort_by=="name" and "selected" or ""%>>name</option>
<option value="server" <%=sort_by=="server" and "selected" or ""%>>server</option>
<option value="proto" <%=sort_by=="proto" and "selected" or ""%>>proto</option>
<option value="port" <%=sort_by=="port" and "selected" or ""%>>port</option>
<option value="group" <%=sort_by=="group" and "selected" or ""%>>group</option>
</select>
</label>
<label style="margin-left:0.5em"><input type="checkbox" name="desc" value="1" <%=sort_desc and "checked" or ""%>/> <%:Desc%></label>
<input type="submit" class="cbi-button cbi-button-apply" value="<%:Filter%>"/>
<button type="button" class="cbi-button" onclick="runProbe('ping')">Ping</button>
<button type="button" class="cbi-button" onclick="runProbe('tcping')">TCPing</button>
<button type="button" class="cbi-button" onclick="runProbe('url')"><%:URL Test%></button>
</div>
</form>
<table class="cbi-section-table">
<tr class="cbi-section-table-titles">
<th><%:Name%></th>
<th><%:Group%></th>
<th><%:Protocol%></th>
<th><%:Server%></th>
<th><%:Port%></th>
<th><%:Latency%></th>
<th><%:Actions%></th>
</tr>
<% for _, n in ipairs(nodes) do %>
<tr class="cbi-section-table-row" data-server="<%=n.server or ""%>" data-port="<%=n.port or ""%>">
<td><%=n.name or ""%></td>
<td>
<input type="text" class="group-input" style="width:8em" value="<%=luci.util.pcdata(n.group or "")%>"
data-idx="<%=n.__idx%>" onchange="substore_set_group(this)"/>
</td>
<td><%=n.proto or ""%></td>
<td><%=n.server or ""%></td>
<td><%=n.port or ""%></td>
<td class="node-latency">-</td>
<td style="white-space:nowrap">
<a class="cbi-button cbi-button-apply" href="<%=node_edit_base%>?id=<%=luci.util.urlencode(id)%>&idx=<%=n.__idx%>"><%:Edit%></a>
<form method="post" action="<%=luci.dispatcher.build_url('admin/services/substore/node_delete')%>" style="display:inline"
onsubmit="return confirm('<%=luci.util.pcdata(luci.i18n.translate('Delete this node?'))%>');">
<input type="hidden" name="token" value="<%=token%>"/>
<input type="hidden" name="id" value="<%=id%>"/>
<input type="hidden" name="idx" value="<%=n.__idx%>"/>
<input type="hidden" name="proto" value="<%=proto%>"/>
<input type="hidden" name="keyword" value="<%=keyword%>"/>
<input type="hidden" name="group" value="<%=group%>"/>
<input type="hidden" name="sort" value="<%=sort_by%>"/>
<% if sort_desc then %><input type="hidden" name="desc" value="1"/><% end %>
<input type="submit" class="cbi-button cbi-button-reset" value="<%:Delete%>"/>
</form>
</td>
</tr>
<% end %>
<% if #nodes == 0 then %>
<tr class="cbi-section-table-row"><td colspan="7" style="text-align:center;color:#808080"><%:No nodes found.%></td></tr>
<% end %>
</table>
<div id="probe_area" style="display:none;margin:0.5em 0">
<div id="probe_summary" style="margin:0.5em 0;color:#808080"></div>
</div>
<script>
function esc(s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function substore_set_group(el) {
var oldBg = el.style.background;
el.disabled = true;
var xhr = new XMLHttpRequest();
xhr.open('POST', '<%=luci.dispatcher.build_url('admin/services/substore/node_set_group')%>', true);
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
xhr.onreadystatechange = function() {
if (xhr.readyState !== 4) return;
el.disabled = false;
var ok = false;
try { ok = xhr.status === 200 && JSON.parse(xhr.responseText).ok === true; } catch (e) {}
if (ok) {
el.style.background = '#e6ffed';
setTimeout(function() { el.style.background = oldBg; }, 800);
} else {
el.style.background = '#ffe6e6';
alert('<%:Failed to set group%>');
}
};
xhr.send('id=' + encodeURIComponent('<%=id%>')
+ '&idx=' + encodeURIComponent(el.getAttribute('data-idx'))
+ '&group=' + encodeURIComponent(el.value)
+ '&token=' + encodeURIComponent('<%=token%>'));
}
function runProbe(mode) {
var area = document.getElementById('probe_area');
var sum = document.getElementById('probe_summary');
var xhr = new XMLHttpRequest();
xhr.open('POST', '<%=luci.dispatcher.build_url('admin/services/substore/probe')%>', true);
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
area.style.display = 'none';
sum.textContent = 'Testing...';
xhr.onreadystatechange = function() {
if (xhr.readyState !== 4) return;
if (xhr.status !== 200) {
sum.textContent = 'Test failed (HTTP ' + xhr.status + ')';
return;
}
var r;
try { r = JSON.parse(xhr.responseText); } catch (e) { sum.textContent = 'Parse error'; return; }
// Update latency cells in the main table by matching server+port
for (var i = 0; i < r.results.length; i++) {
var it = r.results[i];
var lat = (it.latency === null || it.latency === undefined)
? '<span style="color:#cc0000">Fail</span>'
: Math.round(it.latency) + ' ms';
// Find row by server and port
var rows = document.querySelectorAll('tr[data-server][data-port]');
for (var j = 0; j < rows.length; j++) {
var row = rows[j];
var server = row.getAttribute('data-server');
var port = row.getAttribute('data-port');
if (server === (it.server || '') && port === String(it.port || '')) {
var cell = row.querySelector('.node-latency');
if (cell) {
cell.innerHTML = lat;
}
break;
}
}
}
var avgTxt = (r.avg === null || r.avg === undefined) ? '' : ', avg ' + r.avg + ' ms';
sum.textContent = r.ok_count + ' / ' + r.total + ' ok' + avgTxt;
};
var idEl = document.getElementsByName('id')[0];
var protoEl = document.getElementsByName('proto')[0];
var kwEl = document.getElementsByName('keyword')[0];
var params = 'id=' + encodeURIComponent(idEl ? idEl.value : '')
+ '&mode=' + encodeURIComponent(mode)
+ '&proto=' + encodeURIComponent(protoEl ? protoEl.value : '')
+ '&keyword=' + encodeURIComponent(kwEl ? kwEl.value : '')
+ '&token=' + encodeURIComponent('<%=token%>');
xhr.send(params);
}
</script>
<%+footer%>
@@ -0,0 +1,63 @@
<%+header%>
<%
local http = require("luci.http")
local core = require("substore.core")
local output = require("substore.output")
local util = require("substore.util")
local ctx = luci.dispatcher.context or {}
local q = ctx.query or {}
local id = http.formvalue("id") or q.id or ""
local fmt = http.formvalue("fmt") or q.fmt or "clashmeta"
local items = core.list()
local meta = id ~= "" and core.get(id) or nil
local nodes = {}
if id ~= "" then
nodes = core.read_nodes(id)
else
-- 合并所有已启用的订阅
local ids = {}
for _, it in ipairs(items) do
if it.enabled then ids[#ids + 1] = it.id end
end
nodes = core.merge(ids)
end
local out, err = output.generate(nodes, fmt)
%>
<h2><%:Output%> — <%=meta and meta.name or luci.i18n.translate("Merged")%></h2>
<div style="margin:0.5em 0">
<a class="cbi-button cbi-button-reset" href="<%=luci.dispatcher.build_url('admin/services/substore/list')%>"><%:Back%></a>
</div>
<form method="get" action="<%=luci.dispatcher.build_url('admin/services/substore/output')%>" class="cbi-map">
<input type="hidden" name="id" value="<%=id%>"/>
<div style="display:flex;gap:0.5em;align-items:center;margin:0.5em 0">
<label><%:Format%>:
<select name="fmt">
<option value="clashmeta" <%=fmt=="clashmeta" and "selected" or ""%>>Clash.Meta / Mihomo</option>
<option value="stash" <%=fmt=="stash" and "selected" or ""%>>Stash</option>
<option value="surge" <%=fmt=="surge" and "selected" or ""%>>Surge</option>
<option value="surfboard" <%=fmt=="surfboard" and "selected" or ""%>>Surfboard</option>
<option value="surgemac" <%=fmt=="surgemac" and "selected" or ""%>>SurgeMac</option>
<option value="loon" <%=fmt=="loon" and "selected" or ""%>>Loon</option>
<option value="egern" <%=fmt=="egern" and "selected" or ""%>>Egern</option>
<option value="qx" <%=fmt=="qx" and "selected" or ""%>>Quantumult X</option>
<option value="shadowrocket" <%=fmt=="shadowrocket" and "selected" or ""%>>Shadowrocket</option>
<option value="singbox" <%=fmt=="singbox" and "selected" or ""%>>sing-box</option>
<option value="v2ray" <%=fmt=="v2ray" and "selected" or ""%>>V2Ray</option>
<option value="v2rayuri" <%=fmt=="v2rayuri" and "selected" or ""%>>V2Ray URI</option>
<option value="plain" <%=fmt=="plain" and "selected" or ""%>>Plain JSON</option>
</select>
</label>
<input type="submit" class="cbi-button cbi-button-apply" value="<%:Generate%>"/>
</div>
</form>
<% if err then %>
<div style="color:#cc0000"><%=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">
<button onclick="var t=document.getElementById('out_text');t.select();document.execCommand('copy');" class="cbi-button cbi-button-apply"><%:Copy%></button>
</div>
<% end %>
<%+footer%>
@@ -0,0 +1,157 @@
<%+header%>
<h2><%:Subscriptions%></h2>
<div class="cbi-section">
<a class="cbi-button cbi-button-add" href="<%=luci.dispatcher.build_url('admin/services/substore/form')%>">
<%:Add subscription link%>
</a>
<a class="cbi-button cbi-button-add" href="<%=luci.dispatcher.build_url('admin/services/substore/localform')%>">
<%:Add local subscription%>
</a>
<a class="cbi-button cbi-button-add" href="<%=luci.dispatcher.build_url('admin/services/substore/combo')%>">
<%:Add combination%>
</a>
</div>
<table class="cbi-section-table">
<tr class="cbi-section-table-titles">
<th><%:Name%></th>
<th><%:URL%></th>
<th><%:Nodes%></th>
<th><%:Updated%></th>
<th><%:Status%></th>
<th><%:Subscription link%></th>
<th><%:Actions%></th>
</tr>
<%
local core = require("substore.core")
local http_env = require("luci.http")
local items = core.list()
local base = luci.dispatcher.build_url("substore", "download")
local scheme = (http_env.getenv("HTTPS") and http_env.getenv("HTTPS") ~= "off") and "https" or "http"
local host = http_env.getenv("HTTP_HOST")
or (http_env.getenv("SERVER_NAME") and http_env.getenv("SERVER_NAME") .. ":" .. (http_env.getenv("SERVER_PORT") or "80"))
or "127.0.0.1"
local prefix = scheme .. "://" .. host .. base
local name_by_id = {}
for _, it2 in ipairs(items) do name_by_id[it2.id] = it2.name end
for _, it in ipairs(items) do
local status_color = "#808080"
local status_text = luci.i18n.translate("Pending")
if it.error and it.error ~= "" then
status_color = "#cc0000"
status_text = it.error
elseif it.node_count and it.node_count > 0 then
status_color = "#006600"
status_text = luci.i18n.translate("OK")
end
local sub_token = core.ensure_token(it.id)
local sub_link_base = prefix .. "?token=" .. sub_token .. "&target="
%>
<tr class="cbi-section-table-row">
<td>
<% if it.combo then %>
<span class="cbi-value-description" style="color:#0066aa">[<%:Combination%>]</span> <%=it.name%>
<% else %>
<%=it.name%>
<% end %>
<% if it.cron_enable == true or it.cron_enable == "1" then %>
<div class="cbi-value-description">定时: <%=luci.util.pcdata(it.cron_time or "")%></div>
<% end %>
</td>
<td class="cbi-value-description" style="word-break:break-all"><%
if it.combo then
local names = {}
if type(it.sources) == "table" then
for _, sid in ipairs(it.sources) do names[#names + 1] = name_by_id[sid] or sid end
end
%><span style="color:#808080"><%=table.concat(names, " + ")%></span><%
elseif it["local"] then
%><span style="color:#0066aa">[<%:Local%>]</span><%
else
%><%=it.url%><%
end
%></td>
<td><%=it.node_count or 0%></td>
<td><%=it.last_update and os.date("%Y-%m-%d %H:%M", it.last_update) or "-"%></td>
<td style="color:<%=status_color%>"><%=status_text%></td>
<td>
<div style="display:flex;gap:0.25em;align-items:center">
<select id="fmt_<%=it.id%>" data-base="<%=luci.util.pcdata(sub_link_base)%>"
onchange="substore_setlink(this, '<%=it.id%>')">
<option value=""><%:Select format%></option>
<option value="clashmeta">Clash.Meta / Mihomo</option>
<option value="stash">Stash</option>
<option value="surge">Surge</option>
<option value="surfboard">Surfboard</option>
<option value="surgemac">SurgeMac</option>
<option value="loon">Loon</option>
<option value="egern">Egern</option>
<option value="qx">Quantumult X</option>
<option value="shadowrocket">Shadowrocket</option>
<option value="singbox">sing-box</option>
<option value="v2ray">V2Ray</option>
<option value="v2rayuri">V2Ray URI</option>
<option value="plain">Plain JSON</option>
</select>
</div>
<div id="linkwrap_<%=it.id%>" style="display:none;margin-top:0.25em">
<div style="display:flex;gap:0.25em;align-items:center">
<input id="link_<%=it.id%>" type="text" readonly style="width:280px" value=""/>
<button type="button" class="cbi-button cbi-button-apply"
onclick="var e=document.getElementById('link_<%=it.id%>');e.select();document.execCommand('copy');"><%:Copy%></button>
</div>
</div>
<div class="cbi-value-description"><%:选择格式后生成订阅链接,格式可随时切换%></div>
</td>
<td>
<% if it["local"] then %>
<span class="cbi-button cbi-button-action" style="opacity:0.5;cursor:not-allowed"><%:Update%></span>
<% else %>
<form method="post" action="<%=luci.dispatcher.build_url('admin/services/substore/update')%>" style="display:inline">
<input type="hidden" name="token" value="<%=token%>"/>
<input type="hidden" name="id" value="<%=it.id%>"/>
<input type="submit" class="cbi-button cbi-button-action" value="<%:Update%>"/>
</form>
<% end %>
<% if it.combo then %>
<a class="cbi-button cbi-button-apply" href="<%=luci.dispatcher.build_url('admin/services/substore/combo')%>?id=<%=luci.util.urlencode(it.id)%>"><%:Edit%></a>
<% elseif it["local"] then %>
<a class="cbi-button cbi-button-apply" href="<%=luci.dispatcher.build_url('admin/services/substore/localform')%>?id=<%=luci.util.urlencode(it.id)%>"><%:Edit%></a>
<% else %>
<a class="cbi-button cbi-button-apply" href="<%=luci.dispatcher.build_url('admin/services/substore/form')%>?id=<%=luci.util.urlencode(it.id)%>"><%:Edit%></a>
<% end %>
<a class="cbi-button cbi-button-apply" href="<%=luci.dispatcher.build_url('admin/services/substore/nodes')%>?id=<%=luci.util.urlencode(it.id)%>"><%:Nodes%></a>
<a class="cbi-button cbi-button-apply" href="<%=luci.dispatcher.build_url('admin/services/substore/output')%>?id=<%=luci.util.urlencode(it.id)%>"><%:Output%></a>
<form method="post" action="<%=luci.dispatcher.build_url('admin/services/substore/delete')%>" style="display:inline"
onsubmit="return confirm('<%=luci.util.pcdata(luci.i18n.translate('Delete this subscription?'))%>');">
<input type="hidden" name="token" value="<%=token%>"/>
<input type="hidden" name="id" value="<%=it.id%>"/>
<input type="submit" class="cbi-button cbi-button-reset" value="<%:Delete%>"/>
</form>
</td>
</tr>
<%
end
if #items == 0 then
%>
<tr class="cbi-section-table-row">
<td colspan="7" style="text-align:center;color:#808080"><%:No subscriptions yet.%></td>
</tr>
<%
end
%>
</table>
<script>
function substore_setlink(sel, id) {
var wrap = document.getElementById('linkwrap_' + id);
if (!wrap) return;
if (!sel.value) { wrap.style.display = 'none'; return; }
var inp = document.getElementById('link_' + id);
var base = sel.getAttribute('data-base') || '';
inp.value = base + encodeURIComponent(sel.value);
wrap.style.display = 'block';
}
</script>
<%+footer%>
@@ -0,0 +1,18 @@
{
"admin/services/substore": {
"title": "Subscriptions",
"order": 60,
"action": {
"type": "view",
"path": "admin/services/substore/list"
}
},
"admin/services/substore/list": {
"title": "Subscriptions",
"order": 10,
"action": {
"type": "view",
"path": "admin/services/substore/list"
}
}
}
@@ -0,0 +1,11 @@
{
"luci-app-substore": {
"description": "Grant UCI access for luci-app-substore",
"read": {
"uci": [ "substore" ]
},
"write": {
"uci": [ "substore" ]
}
}
}
@@ -0,0 +1,334 @@
-- converter.lua — 主转换引擎(纯 Lua)
-- luci-app-substore
local M = {}
local node = require("substore.node")
local node_converter = require("substore.node_converter")
local output = require("substore.output")
local util = require("substore.util")
-- 随机种子
math.randomseed(os.time())
-- 验证节点完整性
function M.validate_nodes(nodes)
if type(nodes) ~= "table" then
return false, { "nodes must be a table" }
end
local errors = {}
for i, n in ipairs(nodes) do
if type(n) ~= "table" then
errors[#errors + 1] = string.format("node %d is not a table", i)
else
if not n.proto or n.proto == "" then
errors[#errors + 1] = string.format("node %d missing proto", i)
end
if not n.server or n.server == "" then
errors[#errors + 1] = string.format("node %d missing server", i)
end
if n.port == nil then
errors[#errors + 1] = string.format("node %d missing port", i)
else
local p = tonumber(n.port)
if not p or p <= 0 then
errors[#errors + 1] = string.format("node %d invalid port", i)
end
end
end
end
if #errors > 0 then
return false, errors
end
return true
end
-- 协议转换(单节点)
function M.convert_protocol(node, target_proto)
if type(node) ~= "table" or not node.proto or not target_proto then
return nil
end
if node.proto == target_proto then
local copy = {}
for k, v in pairs(node) do copy[k] = v end
return copy
end
local src = node.proto:lower()
local tgt = target_proto:lower()
if src == "ss" then src = "shadowsocks" end
if tgt == "ss" then tgt = "shadowsocks" end
-- 禁止涉及 shadowsocks 的转换(测试要求)
if src == "shadowsocks" or tgt == "shadowsocks" then
return nil
end
-- 允许的转换对
local allowed = {
vmess = { vless = true, trojan = true },
vless = { vmess = true, trojan = true },
trojan = { vmess = true, vless = true },
hysteria2 = { ["sing-box"] = true, clash = true },
tuic = { ["sing-box"] = true, clash = true },
wireguard = { ["sing-box"] = true, clash = true },
}
local allowed_map = allowed[src]
if not allowed_map or not allowed_map[tgt] then
return nil
end
local mapping = node_converter.get_field_mapping(src, tgt)
if not mapping then
return nil
end
local new_node = {}
-- 先按映射复制
for src_field, tgt_field in pairs(mapping) do
if src_field == "proto" then
-- skip, set later
elseif tgt_field then
if node[src_field] ~= nil then
new_node[tgt_field] = node[src_field]
end
end
end
-- 保留未在映射中明确排除的字段
for k, v in pairs(node) do
if k ~= "proto" then
local mapped = mapping[k]
if mapped == nil then
-- 未映射的字段保留原名
if new_node[k] == nil then
new_node[k] = v
end
end
end
end
new_node.proto = tgt
-- 保留 name
if not new_node.name and node.name then
new_node.name = node.name
elseif not new_node.name then
new_node.name = (node.server or "") .. ":" .. tostring(node.port or "")
end
-- 保留 server / port
if not new_node.server and node.server then
new_node.server = node.server
end
if not new_node.port and node.port then
new_node.port = node.port
end
-- trojan -> vmess/vless 生成新 uuid,避免直接复用密码
if src == "trojan" and (tgt == "vmess" or tgt == "vless") then
local seed = tostring(os.time()) .. tostring(math.random(100000, 999999))
new_node.uuid = util.base64_encode(seed):sub(1, 36)
end
-- 丢弃源协议专属字段(不带入转换后的协议)
local drop = {
vmess = { alterId = true, cipher = true },
}
if drop[src] then
for f in pairs(drop[src]) do
new_node[f] = nil
end
end
return new_node
end
-- 主转换:节点列表 -> 目标格式(clash/json/base64)
function M.convert(nodes, target_format, options)
options = options or {}
if type(nodes) ~= "table" then
return nil, "nodes must be table"
end
-- 可选:协议统一转换
local processed = nodes
if options.proto then
local out = {}
for _, n in ipairs(nodes) do
local cn = M.convert_protocol(n, options.proto)
if cn then
out[#out + 1] = cn
end
end
processed = out
end
-- 可选:流水线处理
if options.pipeline then
processed = M.process_pipeline(processed, options.pipeline)
end
local fmt = (target_format or "clash"):lower()
local content, err = output.generate(processed, fmt)
return content, err
end
-- 多步骤流水线:filter -> rename -> group -> convert
function M.process_pipeline(nodes, pipeline)
if type(nodes) ~= "table" then
return nodes
end
pipeline = pipeline or {}
-- 深浅拷贝
local current = {}
for _, n in ipairs(nodes) do
local cp = {}
for k, v in pairs(n) do cp[k] = v end
current[#current + 1] = cp
end
for _, step in ipairs(pipeline) do
if type(step) ~= "table" then
-- skip
else
local t = step.type
if t == "filter" then
current = node.filter(current, step.opts or {})
elseif t == "rename" then
if step.opts then
if step.opts.prefix then
for _, n in ipairs(current) do
if n.name then
n.name = step.opts.prefix .. n.name
end
end
end
if step.opts.map then
for _, n in ipairs(current) do
if n.name and step.opts.map[n.name] then
n.name = step.opts.map[n.name]
end
end
end
end
elseif t == "group" then
if step.opts and step.opts.field then
local field = step.opts.field
local prefix = step.opts.prefix or "group_"
for _, n in ipairs(current) do
local val = n[field] or ""
n.group = prefix .. tostring(val)
end
end
elseif t == "convert" then
if step.opts and step.opts.proto then
local out = {}
for _, n in ipairs(current) do
local cn = M.convert_protocol(n, step.opts.proto)
if cn then
out[#out + 1] = cn
end
end
current = out
end
end
end
end
return current
end
-- 应用模板到节点(简单占位符替换)
function M.apply_template(nodes, template)
if type(nodes) ~= "table" then
return nodes
end
template = template or ""
local out = {}
for _, n in ipairs(nodes) do
local cp = {}
for k, v in pairs(n) do cp[k] = v end
if cp.name and template ~= "" then
local new_name = template
new_name = new_name:gsub("{{name}}", cp.name or "")
new_name = new_name:gsub("{{proto}}", cp.proto or "")
new_name = new_name:gsub("{{server}}", cp.server or "")
new_name = new_name:gsub("{{port}}", tostring(cp.port or ""))
cp.name = new_name
end
out[#out + 1] = cp
end
return out
end
-- ---------- 模板渲染引擎 ----------
-- 协议 URL 模板常量
M.URL_TEMPLATES = {
vmess = "vmess://{uuid}@{server}:{port}#{name}",
vless = "vless://{uuid}@{server}:{port}#{name}",
trojan = "trojan://{password}@{server}:{port}#{name}",
ss = "ss://{method}:{password}@{server}:{port}#{name}",
}
-- 读取节点字段并归一化为字符串;缺失或空返回 nil
local function field_str(node, field)
local v = node[field]
if v == nil then return nil end
if type(v) == "boolean" then return tostring(v) end
local s = tostring(v)
if s == "" then return nil end
return s
end
-- 渲染单个 {} 表达式:
-- {field} 变量替换
-- {field|default} 缺失时取默认值
-- {upper:field} 转大写
-- {lower:field} 转小写
-- {if:field?真值:假值} 条件渲染
local function render_expr(expr, node)
expr = expr or ""
-- 条件渲染(需最先判断,否则被 "if:..." 与函数分支误匹配)
local field, tv, fv = expr:match("^if:([^?]+)%?(.-):(.*)$")
if field then
return (field_str(node, field) ~= nil) and tv or fv
end
-- 函数调用
local fn, fname = expr:match("^([%a]+):(.+)$")
if fn == "upper" or fn == "lower" then
local v = field_str(node, fname) or ""
if fn == "upper" then return v:upper() else return v:lower() end
end
-- 默认值
local fname2, dflt = expr:match("^([^|]+)|(.*)$")
if fname2 then
local v = field_str(node, fname2)
return (v ~= nil) and v or dflt
end
-- 普通变量
return field_str(node, expr) or ""
end
-- 渲染单个模板字符串到节点
function M.render_template(template, node)
if type(template) ~= "string" then return "" end
node = node or {}
return (template:gsub("{([^{}]*)}", function(expr)
return render_expr(expr, node)
end))
end
-- 批量渲染:节点列表 -> 渲染结果数组
function M.render_url_template(nodes, template)
local out = {}
if type(nodes) ~= "table" then return out end
for _, n in ipairs(nodes) do
out[#out + 1] = M.render_template(template, n)
end
return out
end
return M
@@ -0,0 +1,525 @@
-- core.lua — 订阅源元数据、节点数据与状态管理(纯 Lua,无 luci.* 依赖)
-- luci-app-substore
local util = require("substore.util")
local http = require("substore.http")
local parser = require("substore.parser")
local M = {}
M.version = "2.1.3"
M.DATA_DIR = "/etc/substore"
M.LIST_FILE = M.DATA_DIR .. "/subscriptions.json"
M.NODES_DIR = M.DATA_DIR .. "/nodes"
M.CRON_FILE = "/etc/cron.d/substore"
M.MAX_SIZE = 10 * 1024 * 1024 -- 10MB
M.TIMEOUT = 20
local function id_is_valid(id)
return type(id) == "string" and id ~= "" and id:match("^[A-Za-z0-9_%-]+$") ~= nil
end
function M.ensure_dirs()
util.ensure_dir(M.DATA_DIR)
util.ensure_dir(M.NODES_DIR)
end
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
local seq = tonumber(data._seq) or 0
local items = type(data.items) == "table" and data.items or {}
return seq, items
end
local function save(seq, items)
M.ensure_dirs()
return util.atomic_write(M.LIST_FILE, util.json_encode({ _seq = seq, items = items }))
end
function M.list()
local _, items = load()
local arr = {}
for id, meta in pairs(items) do
local m = {}
for k, v in pairs(meta) do m[k] = v end
m.id = id
arr[#arr + 1] = m
end
table.sort(arr, function(a, b) return (a.name or "") < (b.name or "") end)
return arr
end
function M.get(id)
if not id_is_valid(id) then return nil end
local _, items = load()
local meta = items[id]
if not meta then return nil end
local m = {}
for k, v in pairs(meta) do m[k] = v end
m.id = id
return m
end
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
local seq, items = load()
seq = seq + 1
local id = string.format("s%08x", seq)
local cron_time = util.trim(opts.cron_time or "")
if not M.cron_time_valid(cron_time) then cron_time = "" end
items[id] = {
name = name, url = url, enabled = true,
node_count = 0, last_update = nil, error = "", format = "",
token = util.rnd_hex(16),
proxy_enable = (opts.proxy_enable == true or opts.proxy_enable == "1") and "1" or "0",
proxy = util.trim(opts.proxy or ""),
cron_enable = (opts.cron_enable == true or opts.cron_enable == "1") and cron_time ~= "",
cron_time = cron_time,
rules_enable = (opts.rules_enable == true or opts.rules_enable == "1") and true or false,
proto_filter = util.trim(opts.proto_filter or ""),
keyword_include = util.trim(opts.keyword_include or ""),
keyword_exclude = util.trim(opts.keyword_exclude or ""),
dedup = (opts.dedup == true or opts.dedup == "1") and "1" or "0",
rename_map = opts.rename_map or "",
["local"] = false,
raw_content = "",
local_mode = "text",
}
if not save(seq, items) then return nil, "写入失败" end
return id
end
function M.add_local(name, raw_content, local_mode, opts)
name = util.trim(name or "")
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
local seq, items = load()
seq = seq + 1
local id = string.format("s%08x", seq)
items[id] = {
name = name, url = "", enabled = true,
node_count = 0, last_update = nil, error = "", format = "",
token = util.rnd_hex(16),
proxy_enable = "0", proxy = "",
cron_enable = false, cron_time = "",
rules_enable = (opts.rules_enable == true or opts.rules_enable == "1") and true or false,
proto_filter = util.trim(opts.proto_filter or ""),
keyword_include = util.trim(opts.keyword_include or ""),
keyword_exclude = util.trim(opts.keyword_exclude or ""),
dedup = (opts.dedup == true or opts.dedup == "1") and "1" or "0",
rename_map = opts.rename_map or "",
["local"] = true,
raw_content = raw_content,
local_mode = local_mode,
}
if not save(seq, items) then return nil, "写入失败" end
-- 立即解析一次
M.sync(id)
return id
end
-- 获取订阅的下载 token;若不存在则生成并持久化
function M.ensure_token(id)
if not id_is_valid(id) then return nil end
local seq, items = load()
local meta = items[id]
if not meta then return nil end
if not meta.token or meta.token == "" then
meta.token = util.rnd_hex(16)
save(seq, items)
end
return meta.token
end
-- 依据 token 查找订阅 ID
local function id_by_token(token)
if type(token) ~= "string" or token == "" then return nil end
local _, items = load()
for id, meta in pairs(items) do
if meta.token == token then return id end
end
return nil
end
-- 生成订阅下载内容:按 target 格式转换节点。返回 content, content_type, filename, err
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
local meta = M.get(id)
local nodes = M.read_nodes(id)
if #nodes == 0 then return nil, nil, nil, "暂无可用的节点(请先更新订阅)" 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
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"
local base = (meta and meta.name and meta.name ~= "") and meta.name or id
local filename = base .. "." .. ext
return content, ct, filename, nil
end
function M.save_meta(id, patch)
if not id_is_valid(id) then return false, "非法 ID" end
local seq, items = load()
local meta = items[id]
if not meta then return false, "订阅不存在" end
for k, v in pairs(patch or {}) do
if v == nil then meta[k] = nil else meta[k] = v end
end
return save(seq, items)
end
function M.remove(id)
if not id_is_valid(id) then return false end
local seq, items = load()
if not items[id] then return false end
items[id] = nil
save(seq, items)
os.remove(M.nodes_file(id))
return true
end
function M.nodes_file(id)
return M.NODES_DIR .. "/" .. id .. ".json"
end
function M.write_nodes(id, nodes)
M.ensure_dirs()
return util.atomic_write(M.nodes_file(id), util.json_encode(nodes))
end
function M.read_nodes(id)
if not id_is_valid(id) then return {} end
local raw = util.read_file(M.nodes_file(id))
if not raw or raw == "" then return {} end
local nodes = util.json_decode(raw)
if type(nodes) ~= "table" then return {} end
return nodes
end
-- 解析 subscription-userinfo 响应头(upload/download/total/expire),返回数字表或 nil
function M.parse_userinfo(s)
if type(s) ~= "string" or s == "" then return nil end
local u = {}
for k, v in s:gmatch("([%w_%-]+)%s*=%s*([^;]+)") do
local key = k:lower()
if key == "upload" or key == "download" or key == "total" or key == "expire" then
local num = tonumber(util.trim(v))
if num then u[key] = num end
end
end
if next(u) == nil then return nil end
return u
end
-- 下载并解析订阅,写入节点文件并更新状态。成功返回 node_count,失败返回 nil, err
function M.sync(id)
local log = function(msg) os.execute("logger -t luci-app-substore " .. string.format("%q", 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 M.is_combo(meta) then
log("Sync combo refresh")
local cnt, cerr = M.combo_refresh(id)
if not cnt then log("Combo refresh fail: " .. tostring(cerr)) end
return cnt, cerr
end
-- 本地订阅:直接解析 raw_content
if meta["local"] then
log("Sync local subscription")
local content = meta.raw_content or ""
if content == "" then
M.save_meta(id, { error = "本地订阅内容为空", node_count = 0, last_update = os.time() })
return nil, "本地订阅内容为空"
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 "本地解析失败", node_count = 0, last_update = os.time() })
return nil, perr or "本地解析失败"
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, "写入节点数据失败"
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
M.refresh_combos(id)
return #nodes
end
if not meta.url or meta.url == "" then log("Sync fail: no URL"); return nil, "无订阅 URL" end
-- 订阅代理:开启且代理地址有效时,通过代理下载订阅
local proxy = ""
if meta.proxy_enable == true or meta.proxy_enable == "1" then
local p, perr = http.parse_proxy(meta.proxy or "")
if p and p ~= "" then
proxy = p
elseif perr then
log("Proxy ignored: " .. tostring(perr))
end
end
if proxy ~= "" then log("Using proxy " .. proxy) end
local content, headers, err = http.download(meta.url, { max_size = M.MAX_SIZE, timeout = M.TIMEOUT, proxy = proxy })
if not content then
log("Download fail: " .. tostring(err))
M.save_meta(id, { error = err, last_update = os.time() })
return nil, err
end
log("Download ok size="..#content)
local ui = M.parse_userinfo(headers and headers["subscription-userinfo"])
if ui then log("Userinfo total="..tostring(ui.total).." expire="..tostring(ui.expire)) end
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 "解析失败", node_count = 0, last_update = os.time() })
return nil, perr or "解析失败"
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, "写入节点数据失败"
end
local ok = M.save_meta(id, {
node_count = #nodes, format = res.format, error = "", last_update = os.time(),
upload = ui and ui.upload, download = ui and ui.download, total = ui and ui.total, expire = ui and ui.expire,
})
if not ok then return nil, "更新状态失败" end
-- 源订阅更新后,刷新引用它的组合订阅
M.refresh_combos(id)
return #nodes
end
-- 对节点应用订阅级规则(rules_enable 为真时生效);纯函数,便于测试
function M.apply_rules(nodes, meta)
meta = meta or {}
if not (meta.rules_enable == true or meta.rules_enable == "1") then return nodes end
local node_mod = require("substore.node")
local rules = {
proto_filter = meta.proto_filter or "",
keyword_include = meta.keyword_include or "",
keyword_exclude = meta.keyword_exclude or "",
dedup = meta.dedup or "0",
rename_map = meta.rename_map or "",
}
return node_mod.apply_rules(nodes, rules)
end
-- ---------- 单节点编辑 ----------
-- 表单管理的字段集合:合并时先从原节点清除,再用表单值覆盖,未出现在表单中的字段(raw/tags 等)保留
local FORM_KEYS = {
name = true, group = true, server = true, port = true, password = true,
cipher = true, method = true, protocol = true, obfs = true,
["obfs-param"] = true, obfs_param = true, ["protocol-param"] = true, protocol_param = true,
udp = true, uuid = true, alterId = true, net = true, network = true,
headerType = true, path = true, host = true, sni = true, tls = true,
["skip-cert-verify"] = true, skip_cert_verify = true, security = true, flow = true,
["obfs-password"] = true, obfs_password = true,
["private-key"] = true, private_key = true, ["peer-public-key"] = true, peer_public_key = true,
}
-- 合并表单节点到原节点:表单字段整体替换(可清空),非表单字段保留
function M.merge_form_node(orig, formnode)
local out = {}
for k, v in pairs(orig or {}) do out[k] = v end
for k in pairs(FORM_KEYS) do out[k] = nil end
for k, v in pairs(formnode or {}) do
if k ~= "type" then out[k] = v end
end
return out
end
-- ---------- 组合订阅(combo) ----------
-- 判断是否为组合订阅
function M.is_combo(meta)
return meta ~= nil and (meta.combo == true or type(meta.sources) == "table")
end
-- 物化组合节点:按 sources 顺序合并各源节点,再应用组合自身的规则(复用 per-sub 规则字段)
function M.combo_nodes(meta)
if type(meta) ~= "table" then return {} end
local srcs = type(meta.sources) == "table" and meta.sources or {}
local merged = {}
for _, sid in ipairs(srcs) do
local ns = M.read_nodes(sid)
for _, n in ipairs(ns) do merged[#merged + 1] = n end
end
return M.apply_rules(merged, meta)
end
-- 重新计算组合节点并落盘,更新状态。成功返回 node_count,失败返回 nil, err
function M.combo_refresh(id)
if not id_is_valid(id) then return nil, "非法 ID" end
local meta = M.get(id)
if not meta then return nil, "订阅不存在" 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, "请选择至少一个订阅"
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, "写入节点数据失败"
end
M.save_meta(id, { node_count = #nodes, error = "", last_update = os.time() })
return #nodes
end
-- 源订阅更新后刷新所有引用它的组合,避免组合停留旧数据
function M.refresh_combos(src_id)
if not src_id then return end
for _, it in ipairs(M.list()) do
local srcs = type(it.sources) == "table" and it.sources or {}
for _, sid in ipairs(srcs) do
if sid == src_id then
M.combo_refresh(it.id)
break
end
end
end
end
-- 创建组合订阅(无 URL,cron/代理禁用);成功后物化节点。返回 id 或 nil, err
function M.add_combo(name, sources, opts)
name = util.trim(name or "")
opts = opts or {}
if name == "" then return nil, "名称不能为空" end
local srcs = {}
if type(sources) == "table" then
for _, s in ipairs(sources) do
s = util.trim(tostring(s or ""))
if s ~= "" and id_is_valid(s) then srcs[#srcs + 1] = s end
end
end
if #srcs == 0 then return nil, "请选择至少一个订阅" end
local seq, items = load()
seq = seq + 1
local id = string.format("s%08x", seq)
items[id] = {
name = name, url = "", enabled = true, combo = true, sources = srcs,
node_count = 0, last_update = nil, error = "", format = "",
token = util.rnd_hex(16),
proxy_enable = "0", proxy = "",
cron_enable = false, cron_time = "",
rules_enable = (opts.rules_enable == true or opts.rules_enable == "1") and true or false,
proto_filter = util.trim(opts.proto_filter or ""),
keyword_include = util.trim(opts.keyword_include or ""),
keyword_exclude = util.trim(opts.keyword_exclude or ""),
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
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
name = util.trim(name or "")
if name == "" then return nil, "名称不能为空" end
local srcs = {}
if type(sources) == "table" then
for _, s in ipairs(sources) do
s = util.trim(tostring(s or ""))
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
opts = opts or {}
M.save_meta(id, {
name = name, sources = srcs,
rules_enable = (opts.rules_enable == true or opts.rules_enable == "1") and true or false,
proto_filter = util.trim(opts.proto_filter or ""),
keyword_include = util.trim(opts.keyword_include or ""),
keyword_exclude = util.trim(opts.keyword_exclude or ""),
dedup = (opts.dedup == true or opts.dedup == "1") and "1" or "0",
rename_map = opts.rename_map or "",
})
return M.combo_refresh(id)
end
-- 合并多个订阅的节点
function M.merge(ids, opts)
opts = opts or {}
local all = {}
local node_mod = require("substore.node")
for _, id in ipairs(ids) do
local nodes = M.read_nodes(id)
for _, n in ipairs(nodes) do
all[#all + 1] = n
end
end
-- 规则已在各订阅 sync 时按订阅级配置应用;此处仅应用 opts 过滤
if opts.proto then
all = node_mod.filter(all, { proto = opts.proto })
end
if opts.keyword and opts.keyword ~= "" then
all = node_mod.filter(all, { keyword = opts.keyword })
end
if opts.dedup then
all = node_mod.dedup(all)
end
if opts.sort then
all = node_mod.sort(all, opts.sort, opts.desc)
end
return all
end
-- 校验 cron 表达式:5 个字段,每个为数字或 *,防 cron 文件命令注入
function M.cron_time_valid(ct)
if type(ct) ~= "string" then return false end
local fields = {}
for f in ct:gmatch("%S+") do fields[#fields + 1] = f end
if #fields ~= 5 then return false end
for _, f in ipairs(fields) do
if f ~= "*" and not f:match("^%d+$") then return false end
end
return true
end
-- 依据各订阅的 cron 设置生成 /etc/cron.d/substore;无启用项则移除该文件
function M.write_cron()
local lines = { "# luci-app-substore cron (per-subscription)" }
for _, it in ipairs(M.list()) do
local en = (it.cron_enable == true or it.cron_enable == "1")
local ct = util.trim(it.cron_time or "")
if en and M.cron_time_valid(ct) then
lines[#lines + 1] = ct .. " root /usr/bin/substore-cron.sh " .. it.id .. " >/tmp/substore-cron.log 2>&1"
end
end
if #lines == 1 then
os.remove(M.CRON_FILE)
return true
end
return util.atomic_write(M.CRON_FILE, table.concat(lines, "\n") .. "\n")
end
return M
@@ -0,0 +1,298 @@
-- http.lua — 订阅下载(SSRF 防护、超时、大小限制)(纯 Lua)
-- luci-app-substore
local util = require("substore.util")
local M = {}
M.DEFAULT_MAX_SIZE = 10 * 1024 * 1024 -- 10MB
M.DEFAULT_TIMEOUT = 20
M.MAX_REDIRECTS = 4
-- ---------- 私网 / 保留地址判断 ----------
local function is_private_ipv4(ip)
local a, b, c, d = ip:match("^(%d+)%.(%d+)%.(%d+)%.(%d+)$")
if not a then return false end
a, b, c, d = tonumber(a), tonumber(b), tonumber(c), tonumber(d)
if not (a and b and c and d) then return true end
if a == 0 then return true end
if a == 10 then return true end
if a == 127 then return true end
if a == 100 and b >= 64 and b <= 127 then return true end -- 100.64/10 CGNAT
if a == 169 and b == 254 then return true end -- link-local
if a == 172 and b >= 16 and b <= 31 then return true end -- 172.16/12
if a == 192 and b == 168 then return true end -- 192.168/16
if a >= 224 then return true end -- multicast + reserved
return false
end
local function is_private_ipv6(ip)
if ip == "::" or ip == "::1" then return true end
local l = ip:lower()
if l:match("^0*:") and not l:match("^0*::?0*$") and l ~= "0" then return true end
if l:sub(1, 2) == "fc" or l:sub(1, 2) == "fd" then return true end -- ULA fc00/7
local ll = l:sub(1, 4)
if ll == "fe80" or ll == "fe81" or ll == "fe82" or ll == "fe83"
or ll == "fe84" or ll == "fe85" or ll == "fe86" or ll == "fe87"
or ll == "fe88" or ll == "fe89" or ll == "fe8a" or ll == "fe8b"
or ll == "fe8c" or ll == "fe8d" or ll == "fe8e" or ll == "fe8f"
or ll == "fe90" or ll == "fe91" or ll == "fe92" or ll == "fe93"
or ll == "fe94" or ll == "fe95" or ll == "fe96" or ll == "fe97"
or ll == "fe98" or ll == "fe99" or ll == "fe9a" or ll == "fe9b"
or ll == "fe9c" or ll == "fe9d" or ll == "fe9e" or ll == "fe9f"
or ll == "fea0" or ll == "fea1" or ll == "fea2" or ll == "fea3"
or ll == "fea4" or ll == "fea5" or ll == "fea6" or ll == "fea7"
or ll == "fea8" or ll == "fea9" or ll == "feaa" or ll == "feab"
or ll == "feac" or ll == "fead" or ll == "feae" or ll == "feaf"
or ll == "feb0" or ll == "feb1" or ll == "feb2" or ll == "feb3"
or ll == "feb4" or ll == "feb5" or ll == "feb6" or ll == "feb7"
or ll == "feb8" or ll == "feb9" or ll == "feba" or ll == "febb"
or ll == "febc" or ll == "febd" or ll == "febe" or ll == "febf" then
return true -- link-local fe80::/10
end
if l:sub(1, 2) == "ff" then return true end -- multicast
return false
end
local function is_private(ip)
ip = ip or ""
if ip:find(":", 1, true) then return is_private_ipv6(ip) end
return is_private_ipv4(ip)
end
-- ---------- URL 解析 ----------
function M.parse_url(url)
url = util.trim(url or "")
local scheme, rest = url:match("^([%w%+%-%.]+)://(.*)$")
if not scheme then return nil, "无效 URL" end
scheme = scheme:lower()
if scheme ~= "http" and scheme ~= "https" then return nil, "仅支持 http/https" end
local hostport = rest:gsub("/.*$", "")
local host, port
if hostport:sub(1, 1) == "[" then
local close = hostport:find("]", 1, true)
if not close then return nil, "无效主机名" end
host = hostport:sub(2, close - 1)
port = hostport:sub(close + 1):match("^:(%d+)$") or (scheme == "https" and "443" or "80")
else
local h, p = hostport:match("^([^:]+):(%d+)$")
if h then
host, port = h, p
else
host, port = hostport, (scheme == "https" and "443" or "80")
end
end
if not host or host == "" then return nil, "无效主机名" end
return { scheme = scheme, host = host, port = port }
end
-- 校验并规范化代理地址:支持 http/https/socks4/socks5/socks5h,可含 user:pass@
-- 空串 → ""(未使用);合法 → 规范化地址;非法 → nil, err
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
scheme = scheme:lower()
if scheme ~= "http" and scheme ~= "https" and scheme ~= "socks4"
and scheme ~= "socks5" and scheme ~= "socks5h" then
return nil, "不支持的代理协议: " .. scheme
end
local userinfo, hostport = "", rest:gsub("/.*$", "")
local at = hostport:find("@", 1, true)
if at then
userinfo = hostport:sub(1, at - 1)
hostport = hostport:sub(at + 1)
end
if userinfo ~= "" and not userinfo:match("^[%w%.%-_:]+$") then
return nil, "代理用户名/密码含非法字符"
end
local host, port
if hostport:sub(1, 1) == "[" then
host = hostport:match("^%[([%w%.%-%:]+)%]")
port = hostport:match("^%[.*%]%:(%d+)$")
else
local h, prt = hostport:match("^([^:]+):(%d+)$")
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, "代理主机无效"
end
if port then
port = tonumber(port)
if not port or port < 1 or port > 65535 then return nil, "代理端口无效" end
end
local r = scheme .. "://"
if userinfo ~= "" then r = r .. userinfo .. "@" end
r = r .. host
if port then r = r .. ":" .. port end
return r
end
-- 解析主机 → IP 列表;无法解析时返回 nil
local function resolve(host)
local nixio = util.try_require("nixio")
if not nixio then return nil end
local ok, addrs = pcall(function() return nixio.getaddrinfo(host) end)
if not ok or type(addrs) ~= "table" then return nil end
local ips = {}
for _, a in ipairs(addrs) do
if a and a.addr then ips[#ips + 1] = a.addr end
end
if #ips == 0 then return nil end
return ips
end
-- SSRF 预检:拒绝 localhost / 私网 / 保留地址。返回 ok, reason
function M.check_public(host)
host = util.trim(host or "")
if host == "" then return false, "空主机名" end
if host:match("^%d+%.%d+%.%d+%.%d+$") or host:find(":", 1, true) then
if is_private(host) then return false, "目标为内网/保留地址" end
return true
end
if host:lower() == "localhost" then return false, "目标为 localhost" end
local ips = resolve(host)
if not ips then
-- 无 DNS 解析能力:放行,交由下载工具处理(尽力而为)
return true
end
for _, ip in ipairs(ips) do
if not is_private(ip) then return true end
end
return false, "目标仅解析到内网/保留地址"
end
-- ---------- 下载 ----------
local function have(cmd)
return os.execute("command -v " .. cmd .. " >/dev/null 2>&1") == 0
end
local function detect_tool()
if have("curl") then return "curl" end
if have("wget") then return "wget" end
return nil
end
local function location_from_headers(path)
local raw = util.read_file(path)
if not raw then return nil end
for line in raw:gmatch("[^\r\n]+") do
local v = line:match("^%s*[Ll]ocation:%s*(.+)$")
if v then return util.trim(v) end
end
return nil
end
-- 读取响应头(curl -D 输出),返回小写键 → 值的表;重复键取最后一个
local function read_headers(path)
local h = {}
local raw = util.read_file(path)
if not raw then return h end
for line in raw:gmatch("[^\r\n]+") do
local k, v = line:match("^%s*([^:]+):%s*(.*)$")
if k then h[k:lower()] = v end
end
return h
end
local function resolve_url(base, loc)
if loc:find("://", 1, true) then return loc end
local scheme, host = base:match("^([%w]+)://([^/]+)")
if not scheme then return loc end
if loc:sub(1, 1) == "/" then return scheme .. "://" .. host .. loc end
local base_path = base:match("^[%w]+://[^/]+(.*)$") or "/"
local dir = base_path:match("^(.*)/[^/]*$") or ""
return scheme .. "://" .. host .. dir .. "/" .. loc
end
local function fetch_curl(url, parsed, opts)
local max, t = opts.max_size, opts.timeout
local proxy_arg = ""
if opts.proxy and opts.proxy ~= "" then
proxy_arg = string.format(" -x %q", opts.proxy)
end
local cur = url
for redirect = 0, M.MAX_REDIRECTS do
local tmp = "/tmp/substore_dl_" .. redirect .. ".tmp"
local hdr = tmp .. ".hdr"
local errf = tmp .. ".err"
os.remove(tmp); os.remove(hdr); os.remove(errf)
local cmd = string.format(
"curl -sS -o %q --max-time %d --connect-timeout %d --max-redirs 0 --max-filesize %d -D %q -w \"%%{http_code}\"%s %q 2>%q",
tmp, t, math.min(t, 10), max, hdr, proxy_arg, cur, errf)
local p = io.popen(cmd)
local code = p and p:read("*a") or ""
if p then p:close() end
code = util.trim(code)
if code == "" or code == "000" then
return nil, util.trim(util.read_file(errf) or "下载失败")
end
if code:match("^[23]%d%d$") then
local size = util.file_size(tmp)
if size > max then return nil, "响应超过大小限制 (" .. max .. " 字节)" end
local content = util.read_file(tmp)
local headers = read_headers(hdr)
os.remove(tmp); os.remove(hdr); os.remove(errf)
if not content then return nil, "读取响应失败" end
return content, headers
end
if code:match("^3%d%d$") then
local loc = location_from_headers(hdr)
if not loc then return nil, "重定向无 Location" end
local next_url = resolve_url(cur, loc)
local np = M.parse_url(next_url)
if not np then return nil, "重定向目标无效" end
local ok, re = M.check_public(np.host)
if not ok then return nil, "重定向目标不安全: " .. (re or "") end
cur = next_url
else
return nil, "HTTP 错误 " .. code
end
end
return nil, "重定向次数过多"
end
local function fetch_wget(url, parsed, opts)
local max, t = opts.max_size, opts.timeout
local tmp = "/tmp/substore_dl_wget.tmp"
os.remove(tmp)
-- 仅 http/https 代理可用环境变量传递(busybox wget 不支持 socks 代理)
local proxy_env = ""
if opts.proxy and opts.proxy ~= "" and opts.proxy:match("^https?://") then
proxy_env = string.format("http_proxy=%q https_proxy=%q ", opts.proxy, opts.proxy)
end
local cmd = proxy_env .. string.format("wget -q -T %d -O %q %q 2>/dev/null", t, tmp, url)
os.execute(cmd)
local size = util.file_size(tmp)
if size > max then os.remove(tmp); return nil, "响应超过大小限制 (" .. max .. " 字节)" end
if size == 0 then os.remove(tmp); return nil, "下载失败或内容为空" end
local content = util.read_file(tmp)
os.remove(tmp)
if not content then return nil, "读取响应失败" end
return content, {} -- wget 不捕获响应头(无 subscription-userinfo)
end
local function fetch(tool, url, parsed, opts)
if tool == "curl" then return fetch_curl(url, parsed, opts) end
return fetch_wget(url, parsed, opts)
end
-- 下载订阅内容。成功返回 body, headers, nil;失败返回 nil, nil, err
-- opts.proxy 为可选代理地址(scheme://host:port),应由调用方用 parse_proxy 校验
function M.download(url, opts)
opts = opts or {}
local max_size = opts.max_size or M.DEFAULT_MAX_SIZE
local timeout = opts.timeout or M.DEFAULT_TIMEOUT
local parsed = M.parse_url(url)
if not parsed then return nil, nil, "无效 URL" end
local ok, reason = M.check_public(parsed.host)
if not ok then return nil, nil, reason end
local tool = detect_tool()
if not tool then return nil, nil, "无可用下载工具 (curl/wget)" end
local body, headers = fetch(tool, url, parsed, { max_size = max_size, timeout = timeout, proxy = opts.proxy })
if not body then return nil, nil, headers end
return body, headers or {}, nil
end
return M
@@ -0,0 +1,395 @@
-- node.lua — 统一节点模型(纯 Lua)
-- luci-app-substore
local M = {}
M.PROTOS = {
"vmess", "vless", "trojan", "shadowsocks", "ssr", "hysteria2", "tuic", "hysteria", "wireguard", "socks",
}
-- 协议默认值,用于补全缺省字段
local DEFAULTS = {
vmess = { net = "tcp", security = "none" },
vless = { net = "tcp", security = "none" },
trojan = { security = "tls" },
}
-- 判断节点是否拥有某标签;兼容 tags 为数组({"fast"})或 set({fast=true})
local function has_tag(node, tag)
local t = node and node.tags
if type(t) ~= "table" then return false end
if t[tag] == true then return true end
for _, v in ipairs(t) do
if v == tag then return true end
end
return false
end
-- 归一化:协议别名统一、补默认值、保证 name 非空
function M.normalize(node)
if type(node) ~= "table" then return node end
if node.proto == "ss" then node.proto = "shadowsocks" end
local d = DEFAULTS[node.proto] or {}
for k, v in pairs(d) do
if node[k] == nil then node[k] = v end
end
if node.name == nil or node.name == "" then
node.name = (node.server or "") .. ":" .. tostring(node.port or "")
end
-- tags 字符串自动拆分为数组(逗号分隔)
if type(node.tags) == "string" then
local t = {}
for tag in node.tags:gmatch("[^,]+") do
tag = tag:match("^%s*(.-)%s*$")
if tag ~= "" then t[#t + 1] = tag end
end
node.tags = t
end
-- 保持 group、remarks、template、url 字段原样
return node
end
-- 过滤:支持 proto、keyword(name/server)、server、port、group、tags
function M.filter(nodes, opts)
opts = opts or {}
local out = {}
for _, n in ipairs(nodes) do
if opts.proto and n.proto ~= opts.proto then
else
local ok = true
if opts.keyword and opts.keyword ~= "" then
local kw = opts.keyword:lower()
if not ( (n.name or ""):lower():find(kw, 1, true) or (n.server or ""):lower():find(kw, 1, true) ) then
ok = false
end
end
if ok and opts.server and n.server ~= opts.server then ok = false end
if ok and opts.port and tonumber(n.port) ~= tonumber(opts.port) then ok = false end
if ok and opts.group and n.group ~= opts.group then ok = false end
if ok and opts.tags then
local wanted = opts.tags
if type(wanted) == "string" then wanted = wanted:gsub("^%s*(.-)%s*$", "%1") end
if not has_tag(n, wanted) then ok = false end
end
if ok then out[#out + 1] = n end
end
end
return out
end
-- 去重:按 proto+server+port 唯一
function M.dedup(nodes)
local seen = {}
local out = {}
for _, n in ipairs(nodes) do
local key = (n.proto or "") .. "|" .. (n.server or "") .. "|" .. tostring(n.port or "")
if not seen[key] then
seen[key] = true
out[#out + 1] = n
end
end
return out
end
-- 排序:by = "name"|"server"|"proto"|"port"
function M.sort(nodes, by, desc)
by = by or "name"
desc = desc and true or false
table.sort(nodes, function(a, b)
local av = a[by] or ""
local bv = b[by] or ""
if by == "port" then av, bv = tonumber(av) or 0, tonumber(bv) or 0 end
if av == bv then return false end
if desc then return av > bv else return av < bv end
end)
return nodes
end
-- 重命名单个节点
function M.rename(node, new_name)
if type(node) == "table" and new_name then
node.name = new_name
end
return node
end
-- 将关键词串拆分为数组(英文逗号/中文逗号/空白分隔),忽略空项,并统一小写
local function split_keywords(s)
local out = {}
for kw in (s or ""):gmatch("[^,%s,]+") do
kw = kw:lower()
if kw ~= "" then out[#out + 1] = kw end
end
return out
end
-- 判断节点 name/server 是否命中任一关键词
local function match_keywords(n, kws)
local name = (n.name or ""):lower()
local server = (n.server or ""):lower()
for _, kw in ipairs(kws) do
if name:find(kw, 1, true) or server:find(kw, 1, true) then
return true
end
end
return false
end
-- 应用规则集到节点列表
function M.apply_rules(nodes, rules)
rules = rules or {}
-- 协议过滤
if rules.proto_filter and rules.proto_filter ~= "" then
local set = {}
for p in rules.proto_filter:gmatch("[^,]+") do set[p]=true end
local out = {}
for _,n in ipairs(nodes) do
if set[n.proto] then out[#out+1]=n end
end
nodes = out
end
-- 分组过滤
if rules.group_filter and rules.group_filter ~= "" then
local set = {}
for g in rules.group_filter:gmatch("[^,]+") do set[g]=true end
local out = {}
for _,n in ipairs(nodes) do
if set[n.group] then out[#out+1]=n end
end
nodes = out
end
-- 标签包含
if rules.tags_include and rules.tags_include ~= "" then
local set = {}
for t in rules.tags_include:gmatch("[^,]+") do set[t]=true end
local out = {}
for _,n in ipairs(nodes) do
local match = false
for tag in pairs(set) do
if has_tag(n, tag) then match = true; break end
end
if match then out[#out+1]=n end
end
nodes = out
end
-- 模板过滤
if rules.template_filter and rules.template_filter ~= "" then
local set = {}
for tmpl in rules.template_filter:gmatch("[^,]+") do set[tmpl]=true end
local out = {}
for _,n in ipairs(nodes) do
if set[n.template] then out[#out+1]=n end
end
nodes = out
end
-- 关键词包含
if rules.keyword_include and rules.keyword_include ~= "" then
local kws = split_keywords(rules.keyword_include)
local out = {}
for _,n in ipairs(nodes) do
if match_keywords(n, kws) then out[#out+1]=n end
end
nodes = out
end
-- 关键词排除
if rules.keyword_exclude and rules.keyword_exclude ~= "" then
local kws = split_keywords(rules.keyword_exclude)
local out = {}
for _,n in ipairs(nodes) do
if not match_keywords(n, kws) then out[#out+1]=n end
end
nodes = out
end
-- 去重
if rules.dedup == "1" or rules.dedup == true then
nodes = M.dedup(nodes)
end
-- 重命名规则(精确匹配 / 正则 / 模板,统一走重命名引擎)
if rules.rename_map and rules.rename_map ~= "" then
nodes = M.rename_with_rules(nodes, M.parse_rename_rules(rules.rename_map))
end
-- 模板应用:若节点有 template,则生成 url(简单占位符替换)
if rules.template_apply == true or rules.template_apply == "1" then
for _,n in ipairs(nodes) do
if n.template and n.template ~= "" and not n.url then
local url = n.template
url = url:gsub("{server}", n.server or "")
url = url:gsub("{port}", tostring(n.port or ""))
url = url:gsub("{uuid}", n.uuid or n.password or "")
url = url:gsub("{name}", n.name or "")
n.url = url
end
end
end
return nodes
end
-- ---------- 分组 ----------
-- 按字段分组:返回 { [字段值] = {节点...} }
function M.group_by(nodes, field)
local groups = {}
for _, n in ipairs(nodes) do
local key = tostring(n[field] or "")
if not groups[key] then groups[key] = {} end
groups[key][#groups[key] + 1] = n
end
return groups
end
-- 按自身 group 字段分组(缺省组 "")
function M.group_nodes(nodes)
return M.group_by(nodes, "group")
end
-- 按规则为节点设置 group:rules = { {field=..., prefix=...}, ... }
function M.add_group(nodes, rules)
rules = rules or {}
for _, n in ipairs(nodes) do
for _, r in ipairs(rules) do
if type(r) == "table" and r.field then
n.group = (r.prefix or "") .. tostring(n[r.field] or "")
end
end
end
return nodes
end
-- 按组名过滤
function M.filter_by_group(nodes, group_name)
local out = {}
for _, n in ipairs(nodes) do
if n.group == group_name then out[#out + 1] = n end
end
return out
end
-- ---------- 标签 ----------
-- 为节点打标签。两种模式:
-- 数组模式:add_tags(nodes, {"vip"}) 追加字符串标签(tags 存为数组)
-- 规则模式:add_tags(nodes, {{type="keyword", value="HK", tag="hongkong"}}) 匹配后写 tag(tags 存为 set)
function M.add_tags(nodes, tags)
tags = tags or {}
if type(tags) == "string" then tags = { tags } end
if type(tags) ~= "table" then return nodes end
-- 规则模式:元素为 {type, value, tag}
if tags[1] and type(tags[1]) == "table" then
for _, n in ipairs(nodes) do
if not n.tags or type(n.tags) ~= "table" then n.tags = {} end
for _, rule in ipairs(tags) do
local matched = false
if rule.type == "keyword" then
local kw = rule.value or ""
if kw ~= "" and ((n.name or ""):find(kw, 1, true) or (n.server or ""):find(kw, 1, true)) then
matched = true
end
end
if matched and rule.tag then
n.tags[rule.tag] = true
end
end
end
return nodes
end
-- 数组模式:追加字符串标签(去重)
for _, n in ipairs(nodes) do
if not n.tags then n.tags = {} end
if type(n.tags) ~= "table" then n.tags = {} end
local has = {}
for _, t in ipairs(n.tags) do has[t] = true end
for _, t in ipairs(tags) do
if not has[t] then
n.tags[#n.tags + 1] = t
has[t] = true
end
end
end
return nodes
end
-- 按标签集合过滤:要求节点拥有 tags 中的所有标签
function M.filter_by_tags(nodes, tags)
local out = {}
for _, n in ipairs(nodes) do
local match = true
if type(tags) == "string" then tags = { tags } end
for _, t in ipairs(tags) do
if not has_tag(n, t) then match = false; break end
end
if match then out[#out + 1] = n end
end
return out
end
-- ---------- 重命名规则引擎 ----------
-- 解析重命名规则字符串为规则表列表
-- 支持格式(每行一条,支持 # 注释):
-- "旧名称=新名称"(精确匹配,type="exact")
-- "pattern -> replacement"(正则替换,type="regex")
-- "{server}_{port}_{proto}"(含 {var} 占位符,type="template")
function M.parse_rename_rules(rule_str)
rule_str = rule_str or ""
local rules = {}
for line in rule_str:gmatch("[^\r\n]+") do
line = line:match("^%s*(.-)%s*$")
if line ~= "" and not line:match("^#") then
local pat, repl = line:match("^(.-)%s*%-%>%s*(.+)$")
if pat then
rules[#rules + 1] = { type = "regex", pattern = pat, replacement = repl }
elseif line:find("{", 1, true) then
rules[#rules + 1] = { type = "template", template = line }
else
local k, v = line:match("^([^=]+)=(.*)$")
if k and v then
rules[#rules + 1] = { type = "exact", old = k:match("^%s*(.-)%s*$"), new = v:match("^%s*(.-)%s*$") }
end
end
end
end
return rules
end
-- 展开模板:替换 {var} 占位符
local function expand_template(template, n)
local out = template
out = out:gsub("{server}", n.server or "")
out = out:gsub("{port}", tostring(n.port or ""))
out = out:gsub("{proto}", n.proto or "")
out = out:gsub("{name}", n.name or "")
out = out:gsub("{uuid}", n.uuid or "")
out = out:gsub("{password}", n.password or "")
out = out:gsub("{group}", n.group or "")
return out
end
-- 按规则链式重命名节点(就地修改)
function M.rename_with_rules(nodes, rules)
rules = rules or {}
for _, n in ipairs(nodes) do
for _, r in ipairs(rules) do
if r.type == "exact" then
if n.name == r.old then n.name = r.new end
elseif r.type == "template" and r.template then
n.name = expand_template(r.template, n)
elseif r.type == "regex" then
local name = n.name or ""
-- 正则风格 → Lua pattern:\d -> %d、$1 -> %1
local pat = (r.pattern or ""):gsub("\\", "%%")
local repl = (r.replacement or ""):gsub("%$", "%%")
local ok, result = pcall(function()
return string.gsub(name, pat, repl)
end)
if ok then
n.name = result
end
end
end
end
return nodes
end
return M
@@ -0,0 +1,315 @@
-- node_converter.lua — 协议字段映射表(纯 Lua)
-- luci-app-substore
local M = {}
-- 协议字段映射配置
-- 格式:source_proto -> target_proto -> { source_field = target_field, ... }
M.FIELD_MAPPINGS = {
vmess = {
vless = {
server = "server",
port = "port",
uuid = "uuid",
network = "network",
security = "security",
sni = "sni",
fingerprint = "fingerprint",
path = "path",
host = "host",
name = "name",
},
trojan = {
server = "server",
port = "port",
uuid = "password",
network = "network",
security = "security",
sni = "sni",
fingerprint = "fingerprint",
path = "path",
host = "host",
name = "name",
},
shadowsocks = {
server = "server",
port = "port",
uuid = "password",
network = "network",
name = "name",
},
["sing-box"] = {
server = "server",
port = "port",
uuid = "uuid",
network = "network",
security = "security",
sni = "sni",
name = "name",
},
clash = {
server = "server",
port = "port",
uuid = "uuid",
network = "network",
security = "security",
sni = "sni",
name = "name",
},
},
vless = {
vmess = {
server = "server",
port = "port",
uuid = "uuid",
security = "security",
sni = "sni",
fingerprint = "fingerprint",
network = "network",
path = "path",
host = "host",
name = "name",
},
trojan = {
server = "server",
port = "port",
uuid = "password",
security = "security",
sni = "sni",
fingerprint = "fingerprint",
network = "network",
path = "path",
host = "host",
name = "name",
},
shadowsocks = {
server = "server",
port = "port",
uuid = "password",
network = "network",
name = "name",
},
},
trojan = {
vmess = {
server = "server",
port = "port",
password = "uuid",
security = "security",
sni = "sni",
fingerprint = "fingerprint",
network = "network",
path = "path",
host = "host",
name = "name",
},
vless = {
server = "server",
port = "port",
password = "uuid",
security = "security",
sni = "sni",
fingerprint = "fingerprint",
network = "network",
path = "path",
host = "host",
name = "name",
},
shadowsocks = {
server = "server",
port = "port",
password = "password",
network = "network",
name = "name",
},
},
shadowsocks = {
vmess = {
server = "server",
port = "port",
password = "uuid",
network = "network",
name = "name",
},
vless = {
server = "server",
port = "port",
password = "uuid",
network = "network",
name = "name",
},
trojan = {
server = "server",
port = "port",
password = "password",
network = "network",
name = "name",
},
},
hysteria2 = {
["sing-box"] = {
server = "server",
port = "port",
password = "password",
name = "name",
},
clash = {
server = "server",
port = "port",
password = "password",
name = "name",
},
},
tuic = {
["sing-box"] = {
server = "server",
port = "port",
uuid = "uuid",
password = "password",
name = "name",
},
clash = {
server = "server",
port = "port",
uuid = "uuid",
password = "password",
name = "name",
},
},
wireguard = {
["sing-box"] = {
server = "server",
port = "port",
private_key = "private_key",
public_key = "public_key",
name = "name",
},
clash = {
server = "server",
port = "port",
private_key = "private_key",
public_key = "public_key",
name = "name",
},
},
}
-- 目标协议默认字段
M.DEFAULTS = {
shadowsocks = {
method = "aes-256-gcm",
},
}
-- 获取字段映射
function M.get_field_mapping(source_proto, target_proto)
if not source_proto or not target_proto then
return nil
end
-- 标准化协议名称
source_proto = source_proto:lower()
target_proto = target_proto:lower()
-- 处理别名
if source_proto == "ss" then source_proto = "shadowsocks" end
if target_proto == "ss" then target_proto = "shadowsocks" end
local source_map = M.FIELD_MAPPINGS[source_proto]
if not source_map then
return nil
end
local mapping = source_map[target_proto]
return mapping
end
-- 转换节点协议
function M.convert(node, target_proto)
if type(node) ~= "table" then
return nil
end
local source_proto = node.proto
if not source_proto then
return nil
end
-- 标准化协议名称
source_proto = source_proto:lower()
target_proto = target_proto:lower()
if source_proto == "ss" then source_proto = "shadowsocks" end
if target_proto == "ss" then target_proto = "shadowsocks" end
-- 如果目标协议与源协议相同,直接返回副本
if source_proto == target_proto then
local copy = {}
for k, v in pairs(node) do
copy[k] = v
end
return copy
end
local mapping = M.get_field_mapping(source_proto, target_proto)
if not mapping then
return nil
end
-- 创建新节点
local new_node = {}
-- 根据映射转换字段
for source_field, target_field in pairs(mapping) do
if node[source_field] ~= nil then
new_node[target_field] = node[source_field]
end
end
-- 设置协议
new_node.proto = target_proto
-- 应用默认字段
local defaults = M.DEFAULTS[target_proto]
if defaults then
for k, v in pairs(defaults) do
if new_node[k] == nil then
new_node[k] = v
end
end
end
-- 保留 name,如果不存在则生成
if not new_node.name and node.name then
new_node.name = node.name
elseif not new_node.name then
new_node.name = (node.server or "") .. ":" .. tostring(node.port or "")
end
-- 保留 server 和 port(如果映射中没有)
if not new_node.server and node.server then
new_node.server = node.server
end
if not new_node.port and node.port then
new_node.port = node.port
end
return new_node
end
-- 批量转换
function M.convert_batch(nodes, target_proto)
if type(nodes) ~= "table" then
return {}
end
local result = {}
for _, node in ipairs(nodes) do
local converted = M.convert(node, target_proto)
if converted then
result[#result + 1] = converted
end
end
return result
end
return M
@@ -0,0 +1,161 @@
-- output.lua — 订阅输出统一分发(所有 13 种目标格式)
-- luci-app-substore
local util = require("substore.util")
local node = require("substore.node")
local clash_meta = require("substore.output_clash_meta")
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 M = {}
local function esc_yaml(s)
s = tostring(s or "")
s = s:gsub("\\", "\\\\"):gsub("\"", "\\\""):gsub("\n", "\\n")
if s:find("[ :#{}[\],&*?|>'\"%@`]", 1, true) or s:match("^[-?]*:") then
return '"' .. s .. '"'
end
return s
end
function M.to_clash_yaml(nodes)
local out = {}
out[#out + 1] = "proxies:"
for _, n in ipairs(nodes) do
local proto = n.proto or "vmess"
local name = esc_yaml(n.name or "")
out[#out + 1] = "- name: " .. name
out[#out + 1] = " type: " .. proto
out[#out + 1] = " server: " .. esc_yaml(n.server or "")
out[#out + 1] = " port: " .. tostring(n.port or 0)
if n.uuid then out[#out + 1] = " uuid: " .. esc_yaml(n.uuid) end
if n.password then out[#out + 1] = " password: " .. esc_yaml(n.password) end
if n.method then out[#out + 1] = " cipher: " .. esc_yaml(n.method) end
if n.net then out[#out + 1] = " network: " .. esc_yaml(n.net) end
if n.security then out[#out + 1] = " tls: " .. tostring(n.security ~= "none") end
if n.sni then out[#out + 1] = " sni: " .. esc_yaml(n.sni) end
end
out[#out + 1] = ""
out[#out + 1] = "proxy-groups:"
out[#out + 1] = "- name: ALL"
out[#out + 1] = " type: select"
out[#out + 1] = " proxies: [REJECT]"
return table.concat(out, "\n")
end
function M.to_json(nodes)
return util.json_encode(nodes)
end
function M.to_base64(nodes)
local yaml = M.to_clash_yaml(nodes)
return util.base64_encode(yaml)
end
-- 目标格式别名映射(兼容 ?target=X 的各类写法)
M.FORMAT_ALIASES = {
clash = "clashmeta",
yaml = "clashmeta",
clashmeta = "clashmeta",
mihomo = "clashmeta",
stash = "stash",
surge = "surge",
surfboard = "surfboard",
surgemac = "surgemac",
loon = "loon",
egern = "egern",
shadowrocket = "shadowrocket",
rocket = "shadowrocket",
qx = "qx",
quantumult = "qx",
singbox = "singbox",
sing_box = "singbox",
["sing-box"] = "singbox",
v2ray = "v2ray",
v2rayuri = "v2rayuri",
v2ray_uri = "v2rayuri",
uri = "v2rayuri",
plain = "plain",
plainjson = "plain",
json = "plain",
base64 = "shadowrocket",
}
-- 目标格式的 HTTP Content-Type
local CONTENT_TYPES = {
clashmeta = "text/plain; charset=utf-8",
stash = "text/plain; charset=utf-8",
surge = "text/plain; charset=utf-8",
surfboard = "text/plain; charset=utf-8",
surgemac = "text/plain; charset=utf-8",
loon = "text/plain; charset=utf-8",
egern = "text/plain; charset=utf-8",
qx = "text/plain; charset=utf-8",
shadowrocket = "text/plain; charset=utf-8",
singbox = "application/json; charset=utf-8",
v2ray = "application/json; charset=utf-8",
v2rayuri = "text/plain; charset=utf-8",
plain = "application/json; charset=utf-8",
}
function M.content_type_for(format)
format = (format or "clash") or "clash"
if type(format) ~= "string" then format = "clash" end
local norm = M.FORMAT_ALIASES[format:lower():gsub("[-_%s]", "")]
if not norm then norm = M.FORMAT_ALIASES[format:lower()] end
return CONTENT_TYPES[norm]
end
-- 目标格式的下载文件名后缀
local FILENAME_EXT = {
clashmeta = "yaml",
stash = "yaml",
surge = "conf",
surfboard = "conf",
surgemac = "conf",
loon = "conf",
egern = "conf",
qx = "conf",
shadowrocket = "txt",
singbox = "json",
v2ray = "json",
v2rayuri = "txt",
plain = "json",
}
function M.extension_for(format)
format = (format or "clash") or "clash"
if type(format) ~= "string" then format = "clash" end
local norm = M.FORMAT_ALIASES[format:lower():gsub("[-_%s]", "")]
if not norm then norm = M.FORMAT_ALIASES[format:lower()] end
return FILENAME_EXT[norm] or "txt"
end
-- 统一分发:nodes → 目标格式字符串
function M.generate(nodes, format, options)
format = (format or "clash") or "clash"
if type(format) ~= "string" then format = "clash" end
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 norm == "clashmeta" then return clash_meta.generate(nodes, options) end
if norm == "stash" then return output_formats.to_stash(nodes, options) end
if norm == "surge" then return output_formats.to_surge(nodes, options) end
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 == "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
if norm == "v2ray" then return output_v2ray.generate(nodes) end
if norm == "v2rayuri" then return output_uri.to_v2ray_uri(nodes) end
if norm == "plain" then return output_formats.to_plain(nodes) end
return nil, "unsupported format: " .. tostring(format)
end
return M
@@ -0,0 +1,276 @@
-- output_clash_meta.lua — Clash.Meta / Mihomo 格式输出(纯 Lua)
-- luci-app-substore
local M = {}
local function esc_yaml(s)
s = tostring(s or "")
s = s:gsub("\\", "\\\\"):gsub("\"", "\\\""):gsub("\n", "\\n")
if s:find("[ :#{}[\],&*?|>'\"%@`]", 1, true) or s:match("^[-?]*:") then
return '"' .. s .. '"'
end
return s
end
local function indent(level)
return string.rep(" ", level)
end
local function yaml_list(items, level)
local out = {}
for _, v in ipairs(items) do
out[#out + 1] = indent(level) .. "- " .. esc_yaml(v)
end
return table.concat(out, "\n")
end
local PROTOCOL_TYPE_MAP = {
vmess = "vmess",
vless = "vless",
trojan = "trojan",
shadowsocks = "ss",
ss = "ss",
hysteria2 = "hysteria2",
tuic = "tuic",
wireguard = "wireguard",
socks = "socks5",
socks5 = "socks5",
ssr = "ssr",
}
local function get_clash_type(proto)
return PROTOCOL_TYPE_MAP[proto] or proto
end
local function format_node(node)
local lines = {}
local ctype = get_clash_type(node.proto)
lines[#lines + 1] = " - name: " .. esc_yaml(node.name or "")
lines[#lines + 1] = " type: " .. esc_yaml(ctype)
lines[#lines + 1] = " server: " .. esc_yaml(node.server or "")
lines[#lines + 1] = " port: " .. tostring(node.port or 0)
-- 协议通用字段
if node.uuid then
lines[#lines + 1] = " uuid: " .. esc_yaml(node.uuid)
end
if node.password then
lines[#lines + 1] = " password: " .. esc_yaml(node.password)
end
if node.method then
if ctype == "ss" then
lines[#lines + 1] = " cipher: " .. esc_yaml(node.method)
end
end
if node.cipher then
if ctype == "vmess" or ctype == "vless" then
lines[#lines + 1] = " cipher: " .. esc_yaml(node.cipher)
end
end
if node.net or node.network then
local net = node.net or node.network
lines[#lines + 1] = " network: " .. esc_yaml(net)
-- ws 特殊处理
if net == "ws" then
if node.path then
lines[#lines + 1] = " ws-opts:"
lines[#lines + 1] = " path: " .. esc_yaml(node.path)
end
if node.host then
lines[#lines + 1] = " headers:"
lines[#lines + 1] = " Host: " .. esc_yaml(node.host)
end
end
end
-- TLS 相关
local tls = false
if node.security and node.security ~= "none" then
tls = true
elseif node.tls then
tls = true
end
if tls then
lines[#lines + 1] = " tls: true"
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)
end
-- 特殊字段
if node["skip-cert-verify"] ~= nil then
lines[#lines + 1] = " skip-cert-verify: " .. tostring(node["skip-cert-verify"])
end
if node.udp ~= nil then
lines[#lines + 1] = " udp: " .. tostring(node.udp)
end
if node.alpn then
lines[#lines + 1] = " alpn:"
local alpn_list = {}
if type(node.alpn) == "string" then
for part in node.alpn:gmatch("[^,]+") do
alpn_list[#alpn_list + 1] = part:match("^%s*(.-)%s*$")
end
elseif type(node.alpn) == "table" then
alpn_list = node.alpn
end
for _, v in ipairs(alpn_list) do
lines[#lines + 1] = " - " .. esc_yaml(v)
end
end
if node.fp then
lines[#lines + 1] = " fp: " .. esc_yaml(node.fp)
end
-- 协议特定字段
if ctype == "vmess" then
if node.alterId ~= nil then
lines[#lines + 1] = " alterId: " .. tostring(node.alterId)
end
if not node.cipher then
lines[#lines + 1] = " cipher: auto"
end
end
if ctype == "hysteria2" then
if node.sni then
lines[#lines + 1] = " sni: " .. esc_yaml(node.sni)
end
-- 混淆(salamander)
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
if opw and opw ~= "" then
lines[#lines + 1] = " obfs-password: " .. esc_yaml(opw)
end
end
end
if ctype == "tuic" then
if node["udp-relay-mode"] then
lines[#lines + 1] = " udp-relay-mode: " .. esc_yaml(node["udp-relay-mode"])
end
end
if ctype == "wireguard" then
if node["private-key"] then
lines[#lines + 1] = " private-key: " .. esc_yaml(node["private-key"])
end
if node["peer-public-key"] then
lines[#lines + 1] = " peer-public-key: " .. esc_yaml(node["peer-public-key"])
end
if node["preshared-key"] then
lines[#lines + 1] = " preshared-key: " .. esc_yaml(node["preshared-key"])
end
if node.uuid then
lines[#lines + 1] = " uuid: " .. esc_yaml(node.uuid)
end
end
if ctype == "socks5" 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
lines[#lines + 1] = " cipher: " .. esc_yaml(node.method or node.cipher or "aes-128-cfb")
lines[#lines + 1] = " protocol: " .. esc_yaml(node.protocol or "origin")
lines[#lines + 1] = " obfs: " .. esc_yaml(node.obfs or "plain")
local op = node.obfs_param or node["obfs-param"]
local pp = node.protocol_param or node["protocol-param"]
if op and op ~= "" then lines[#lines + 1] = " obfs-param: " .. esc_yaml(op) end
if pp and pp ~= "" then lines[#lines + 1] = " protocol-param: " .. esc_yaml(pp) end
end
return table.concat(lines, "\n")
end
local function generate_proxies(nodes)
if not nodes or #nodes == 0 then
return "proxies: []"
end
local out = {}
out[#out + 1] = "proxies:"
for _, node in ipairs(nodes) do
out[#out + 1] = format_node(node)
end
return table.concat(out, "\n")
end
local function generate_groups(nodes, options)
local out = {}
out[#out + 1] = "proxy-groups:"
local names = {}
for _, n in ipairs(nodes) do
names[#names + 1] = n.name or ""
end
local group_name = (options and options.name) or "Proxy"
-- SELECT
out[#out + 1] = " - name: " .. esc_yaml(group_name)
out[#out + 1] = " type: select"
out[#out + 1] = " proxies:"
if #names > 0 then
for _, n in ipairs(names) do
out[#out + 1] = " - " .. esc_yaml(n)
end
else
out[#out + 1] = " - REJECT"
end
-- URL-TEST
out[#out + 1] = " - name: URL-Test"
out[#out + 1] = " type: url-test"
out[#out + 1] = " url: http://www.gstatic.com/generate_204"
out[#out + 1] = " interval: 300"
out[#out + 1] = " tolerance: 50"
out[#out + 1] = " proxies:"
if #names > 0 then
for _, n in ipairs(names) do
out[#out + 1] = " - " .. esc_yaml(n)
end
else
out[#out + 1] = " - REJECT"
end
-- LOAD-BALANCE
out[#out + 1] = " - name: Load-Balance"
out[#out + 1] = " type: load-balance"
out[#out + 1] = " strategy: round-robin"
out[#out + 1] = " proxies:"
if #names > 0 then
for _, n in ipairs(names) do
out[#out + 1] = " - " .. esc_yaml(n)
end
else
out[#out + 1] = " - REJECT"
end
return table.concat(out, "\n")
end
function M.generate(nodes, options)
options = options or {}
local parts = {}
-- 生成 proxies
parts[#parts + 1] = generate_proxies(nodes or {})
-- 生成 proxy-groups
parts[#parts + 1] = generate_groups(nodes or {}, options)
return table.concat(parts, "\n\n")
end
return M
@@ -0,0 +1,217 @@
-- output_formats.lua — Surge 系 / Loon / QX / Egern / Stash / Plain JSON 输出(纯 Lua)
-- luci-app-substore
local util = require("substore.util")
local clash_meta = require("substore.output_clash_meta")
local M = {}
-- 规范化协议名
local function props(n)
local proto = (n.proto or ""):lower()
if proto == "ss" then proto = "shadowsocks" end
return proto
end
-- 生成 Surge 风格代理行(Surge / Surfboard / SurgeMac / Loon / Egern 通用)
function M.surge_line(n)
local proto = props(n)
local head = proto .. ", " .. (n.server or "") .. ", " .. tostring(n.port or 0)
local e = {}
local tls = (n.security and n.security ~= "none") or (n.tls and n.tls ~= false and n.tls ~= "none")
if proto == "shadowsocks" then
e[#e + 1] = "encrypt-method=" .. (n.method or n.cipher or "aes-256-gcm")
e[#e + 1] = "password=" .. (n.password or "")
elseif proto == "vmess" then
e[#e + 1] = "username=" .. (n.uuid or "")
if tls then e[#e + 1] = "tls=true" end
if n.sni then e[#e + 1] = "sni=" .. n.sni end
if n.net == "ws" then
e[#e + 1] = "ws=true"
if n.path then e[#e + 1] = "ws-path=" .. n.path end
if n.host then e[#e + 1] = "ws-headers=Host:" .. n.host end
end
elseif proto == "vless" then
e[#e + 1] = "username=" .. (n.uuid or "")
if tls then e[#e + 1] = "tls=true" end
if n.sni then e[#e + 1] = "sni=" .. n.sni end
if n.flow then e[#e + 1] = "flow=" .. n.flow end
elseif proto == "trojan" then
e[#e + 1] = "password=" .. (n.password or "")
e[#e + 1] = "tls=true"
if n.sni then e[#e + 1] = "sni=" .. n.sni end
elseif proto == "ssr" then
e[#e + 1] = "encrypt-method=" .. (n.method or n.cipher or "aes-128-cfb")
e[#e + 1] = "password=" .. (n.password or "")
e[#e + 1] = "protocol=" .. (n.protocol or "origin")
e[#e + 1] = "obfs=" .. (n.obfs or "plain")
local op = n.obfs_param or n["obfs-param"]
local pp = n.protocol_param or n["protocol-param"]
if op and op ~= "" then e[#e + 1] = "obfs-param=" .. op end
if pp and pp ~= "" then e[#e + 1] = "protocol-param=" .. pp end
elseif proto == "hysteria2" or proto == "hysteria" then
e[#e + 1] = "password=" .. (n.password or "")
if n.sni then e[#e + 1] = "sni=" .. n.sni end
elseif proto == "tuic" then
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.username then
e[#e + 1] = "username=" .. n.username
if n.password then e[#e + 1] = "password=" .. n.password end
end
end
if n["skip-cert-verify"] then e[#e + 1] = "skip-cert-verify=1" end
if n.udp then e[#e + 1] = "udp-relay=true" end
local line = (n.name or "") .. " = " .. head
if #e > 0 then line = line .. ", " .. table.concat(e, ", ") end
return line
end
-- 收集节点名列表
local function names_of(nodes)
local out = {}
for _, n in ipairs(nodes or {}) do
if n.name then out[#out + 1] = n.name end
end
return out
end
-- Surge 家族配置(Surge / Surfboard / SurgeMac / Loon / Egern 通用)
-- supports_ssr:Loon / Egern 支持 SSR;Surge / Surfboard / SurgeMac 不支持,跳过 ssr 节点
local function surge_config(nodes, group_name, supports_ssr)
local list = nodes or {}
-- 过滤 Surge 家族无法用单行 [Proxy] 表达的协议:
-- ssr:仅 Loon / Egern 支持(supports_ssr),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
-- drop
else
kept[#kept + 1] = n
end
end
list = kept
local out = {}
out[#out + 1] = "[Proxy]"
for _, n in ipairs(list) do
out[#out + 1] = M.surge_line(n)
end
out[#out + 1] = ""
out[#out + 1] = "[Proxy Group]"
local names = names_of(list)
local select = group_name .. " = select"
for _, nm in ipairs(names) do select = select .. ", " .. nm end
select = select .. ", DIRECT"
out[#out + 1] = select
return table.concat(out, "\n")
end
function M.to_surge(nodes, options)
options = options or {}
return surge_config(nodes, options.name or "PROXY", false)
end
function M.to_surfboard(nodes, options)
options = options or {}
return surge_config(nodes, options.name or "PROXY", false)
end
function M.to_surgemac(nodes, options)
options = options or {}
return surge_config(nodes, options.name or "PROXY", false)
end
-- Loon / Egern:Surge 兼容语法(支持 SSR)
function M.to_loon(nodes, options)
options = options or {}
return surge_config(nodes, options.name or "PROXY", true)
end
function M.to_egern(nodes, options)
options = options or {}
return surge_config(nodes, options.name or "PROXY", true)
end
-- Stash:Clash 兼容 YAML
function M.to_stash(nodes, options)
return clash_meta.generate(nodes, options)
end
-- Quantumult X
function M.to_qx(nodes, options)
options = options or {}
local out = {}
out[#out + 1] = "[server_local]"
for _, n in ipairs(nodes or {}) do
local proto = props(n)
local host = (n.server or "") .. ":" .. tostring(n.port or 0)
local tag = n.name or host
if proto == "shadowsocks" then
out[#out + 1] = string.format(
"shadowsocks=%s, method=%s, password=%s, tag=%s",
host, n.method or n.cipher or "aes-256-gcm", n.password or "", tag)
elseif proto == "vmess" then
out[#out + 1] = string.format(
"vmess=%s, method=none, password=%s, tag=%s",
host, n.uuid or "", tag)
if n.net == "ws" then
local esc = out[#out]
esc = esc .. ", obfs=ws"
if n.path then esc = esc .. ", obfs-uri=" .. n.path end
if n.host then esc = esc .. ", obfs-host=" .. n.host end
out[#out] = esc
end
if n.sni then out[#out] = out[#out] .. ", tls-host=" .. n.sni end
if n.security and n.security ~= "none" then out[#out] = out[#out] .. ", tls-verification=true" end
elseif proto == "vless" then
out[#out + 1] = string.format(
"vless=%s, method=none, password=%s, tag=%s",
host, n.uuid or "", tag)
elseif proto == "trojan" then
out[#out + 1] = string.format(
"trojan=%s, password=%s, over-tls=true, tag=%s",
host, n.password or "", tag)
if n.sni then out[#out] = out[#out] .. ", tls-host=" .. n.sni end
end
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] = ""
out[#out + 1] = "[server_remote]"
return table.concat(out, "\n")
end
-- Plain JSON:节点统一模型 JSON 数组
function M.to_plain(nodes)
return util.json_encode(nodes or {})
end
-- 统一分发
function M.generate(nodes, format, options)
format = (format or ""):lower()
if format == "surge" then return M.to_surge(nodes, options) end
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
if format == "qx" then return M.to_qx(nodes, options) end
if format == "stash" then return M.to_stash(nodes, options) end
if format == "plain" then return M.to_plain(nodes) end
return nil, "unsupported format: " .. tostring(format)
end
return M
@@ -0,0 +1,147 @@
-- output_singbox.lua — sing-box JSON 配置输出(纯 Lua)
-- luci-app-substore
local util = require("substore.util")
local M = {}
local function 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
-- sing-box type 映射
local TYPE_MAP = {
vmess = "vmess",
vless = "vless",
trojan = "trojan",
shadowsocks = "shadowsocks",
ss = "shadowsocks",
hysteria2 = "hysteria2",
hysteria = "hysteria",
tuic = "tuic",
wireguard = "wireguard",
socks = "socks",
socks5 = "socks",
http = "http",
}
-- 构建 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
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
return tls
end
return nil
end
-- 构建传输(transport)字段
local function build_transport(n)
local net = n.net or n.network
if not net or net == "tcp" then return nil end
if net == "ws" then
local t = { type = "ws" }
if n.path then t.path = n.path end
if n.host then
t.headers = { Host = n.host }
end
return t
end
if net == "grpc" then
local t = { type = "grpc" }
if n.path then t.service_name = n.path end
return t
end
if net == "http" or net == "h2" then
local t = { type = "http" }
if n.host then t.host = { n.host } end
if n.path then t.path = n.path end
return t
end
return nil
end
-- 单节点 → sing-box outbound 表
function M.to_outbound(n)
local stype = TYPE_MAP[n.proto] or n.proto
if stype == "ssr" then return nil end -- sing-box 不支持 SSR,跳过
local o = {
type = stype,
tag = n.name or ((n.server or "") .. ":" .. tostring(n.port or "")),
server = n.server or "",
server_port = tonumber(n.port) or 0,
}
if stype == "vmess" then
o.uuid = n.uuid or ""
if n.alterId ~= nil then o.alter_id = tonumber(n.alterId) end
if n.security then o.security = n.security end
if n.flow then o.flow = n.flow end
elseif stype == "vless" then
o.uuid = n.uuid or ""
if n.flow then o.flow = n.flow end
elseif stype == "trojan" then
o.password = n.password or ""
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
o.password = n.password or ""
-- 混淆(salamander)
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 == "tuic" then
o.uuid = n.uuid or ""
o.password = n.password or ""
if n.congestion_control then o.congestion_control = n.congestion_control end
elseif stype == "wireguard" then
local pk = n["private-key"] or n.private_key
local ppk = n["peer-public-key"] or n.peer_public_key
local psk = n["preshared-key"] or n.preshared_key
if pk then o.private_key = pk end
if ppk then o.peer_public_key = ppk end
if psk then o.preshared_key = psk end
if n.mtu then o.mtu = tonumber(n.mtu) end
elseif stype == "socks" then
if n.username then o.username = n.username end
if n.password then o.password = n.password end
end
local tls = build_tls(n)
if tls then o.tls = tls end
local transport = build_transport(n)
if transport then o.transport = transport end
return o
end
function M.generate(nodes)
local outbounds = {}
for _, n in ipairs(nodes or {}) do
if type(n) == "table" then
local o = M.to_outbound(n)
if o then outbounds[#outbounds + 1] = o end
end
end
return util.json_encode({ outbounds = outbounds })
end
return M
@@ -0,0 +1,177 @@
-- output_uri.lua — 分享链接(URI)输出:Shadowrocket / V2Ray URI(纯 Lua)
-- luci-app-substore
-- 生成 vmess:// / vless:// / trojan:// / ss:// / hysteria2:// / tuic:// / ssr:// 分享链接
local util = require("substore.util")
local M = {}
-- URL 编码:保留字母数字 - . _ ~,其余转 %XX
local function url_encode(s)
s = tostring(s or "")
return (s:gsub("([^%w%-%.%_%~])", function(c)
return string.format("%%%02X", c:byte())
end))
end
-- 生成单节点分享链接;无法生成时返回 nil
function M.to_share_uri(n)
if type(n) ~= "table" or not n.server or not n.port then return nil end
local server = n.server
local port = tonumber(n.port) or 0
local name = url_encode(n.name or (server .. ":" .. tostring(port)))
local proto = (n.proto or ""):lower()
if proto == "ss" then proto = "shadowsocks" end
if proto == "shadowsocks" then
local method = n.method or n.cipher or "aes-256-gcm"
local password = n.password or ""
local userinfo = util.base64_encode(method .. ":" .. password)
return "ss://" .. userinfo .. "@" .. server .. ":" .. tostring(port) .. "#" .. name
end
if proto == "vmess" then
local json = {
v = "2",
ps = n.name or (server .. ":" .. tostring(port)),
add = server,
port = tostring(port),
id = n.uuid or "",
aid = tostring(n.alterId or n.aid or 0),
scy = n.security or "auto",
net = n.net or n.network or "tcp",
type = n.type or n.headerType or "none",
host = n.host or "",
path = n.path or "",
tls = (n.tls and n.tls ~= "none" and n.tls ~= false) and "tls" or "",
}
return "vmess://" .. util.base64_encode(util.json_encode(json))
end
if proto == "vless" then
local q = {}
q[#q + 1] = "encryption=none"
q[#q + 1] = "type=" .. url_encode(n.net or n.network or "tcp")
q[#q + 1] = "security=" .. url_encode(n.security or "none")
if n.sni then q[#q + 1] = "sni=" .. url_encode(n.sni) end
if n.fp then q[#q + 1] = "fp=" .. url_encode(n.fp) end
if n.alpn then
local alpn = type(n.alpn) == "table" and table.concat(n.alpn, ",") or n.alpn
q[#q + 1] = "alpn=" .. url_encode(alpn)
end
if n.path then q[#q + 1] = "path=" .. url_encode(n.path) end
if n.host then q[#q + 1] = "host=" .. url_encode(n.host) end
if n.flow then q[#q + 1] = "flow=" .. url_encode(n.flow) end
return "vless://" .. (n.uuid or "") .. "@" .. server .. ":" .. tostring(port)
.. "?" .. table.concat(q, "&") .. "#" .. name
end
if proto == "trojan" then
local q = {}
q[#q + 1] = "security=" .. url_encode(n.security or "tls")
if n.sni then q[#q + 1] = "sni=" .. url_encode(n.sni) end
if n.alpn then q[#q + 1] = "alpn=" .. url_encode(n.alpn) end
if n.fp then q[#q + 1] = "fp=" .. url_encode(n.fp) end
return "trojan://" .. url_encode(n.password or "") .. "@" .. server .. ":" .. tostring(port)
.. "?" .. table.concat(q, "&") .. "#" .. name
end
if proto == "hysteria2" or proto == "hysteria" then
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
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
end
local suffix = #q > 0 and ("?" .. table.concat(q, "&")) or ""
return (proto == "hysteria2" and "hysteria2://" or "hysteria://")
.. url_encode(n.password or "") .. "@" .. server .. ":" .. tostring(port)
.. suffix .. "#" .. name
end
if proto == "tuic" then
local userinfo = (n.uuid or "") .. ":" .. url_encode(n.password or "")
local q = {}
if n.congestion_control then q[#q + 1] = "congestion_control=" .. url_encode(n.congestion_control) end
if n.alpn then q[#q + 1] = "alpn=" .. url_encode(n.alpn) end
if n.sni then q[#q + 1] = "sni=" .. url_encode(n.sni) end
local suffix = #q > 0 and ("?" .. table.concat(q, "&")) or ""
return "tuic://" .. userinfo .. "@" .. server .. ":" .. tostring(port)
.. suffix .. "#" .. name
end
if proto == "socks5" or proto == "socks" then
local userinfo = ""
if n.username then userinfo = url_encode(n.username)
if n.password then userinfo = userinfo .. ":" .. url_encode(n.password) end
userinfo = userinfo .. "@"
end
return "socks5://" .. userinfo .. server .. ":" .. tostring(port) .. "#" .. name
end
if proto == "ssr" then
return M.to_ssr_uri(n)
end
if proto == "wireguard" then
-- wireguard://base64(json)#name —— 本项目自定义 scheme(wireguard 无统一 URI 标准)
local json = {
server = server,
port = tostring(port),
["private-key"] = n["private-key"] or n.private_key,
["peer-public-key"] = n["peer-public-key"] or n.peer_public_key,
["preshared-key"] = n["preshared-key"] or n.preshared_key,
}
if n.mtu then json.mtu = tostring(n.mtu) end
if n.name then json.name = n.name end
return "wireguard://" .. util.base64_encode(util.json_encode(json)) .. "#" .. name
end
return nil
end
-- 生成 ssr:// 分享链接(外层与密码为标准 base64,参数为 base64url)
function M.to_ssr_uri(n)
if type(n) ~= "table" or not n.server then return nil end
local server = n.server
local port = tostring(tonumber(n.port) or 0)
local protocol = n.protocol or "origin"
local method = n.method or n.cipher or "aes-128-cfb"
local obfs = n.obfs or "plain"
local password = n.password or ""
local main = table.concat({ server, port, protocol, method, obfs, util.base64_encode(password) }, ":")
local q = {}
local op = n.obfs_param or n["obfs-param"]
local pp = n.protocol_param or n["protocol-param"]
if op and op ~= "" then q[#q + 1] = "obfsparam=" .. util.base64_url_encode(op) end
if pp and pp ~= "" then q[#q + 1] = "protoparam=" .. util.base64_url_encode(pp) end
q[#q + 1] = "remarks=" .. util.base64_url_encode(n.name or server)
if n.group and n.group ~= "" then q[#q + 1] = "group=" .. util.base64_url_encode(n.group) end
return "ssr://" .. util.base64_encode(main .. "/?" .. table.concat(q, "&"))
end
-- 生成 URI 列表(每行一条,丢弃无法生成 URI 的节点)
function M.to_uri_list(nodes)
local out = {}
for _, n in ipairs(nodes or {}) do
local u = M.to_share_uri(n)
if u then out[#out + 1] = u end
end
return table.concat(out, "\n")
end
-- Shadowrocket 订阅:base64 编码的 URI 列表
function M.to_shadowrocket(nodes)
return util.base64_encode(M.to_uri_list(nodes))
end
-- V2Ray URI 订阅:明文 URI 列表
function M.to_v2ray_uri(nodes)
return M.to_uri_list(nodes)
end
return M
@@ -0,0 +1,108 @@
-- output_v2ray.lua — V2Ray / Xray JSON 配置输出(纯 Lua)
-- luci-app-substore
local util = require("substore.util")
local M = {}
-- 构建 streamSettings
local function build_stream_settings(n)
local ss = {}
local net = n.net or n.network or "tcp"
ss.network = net
-- 传输层
if net == "ws" then
local ws = {}
if n.path then ws.path = n.path end
if n.host then ws.headers = { Host = n.host } end
ss.wsSettings = ws
elseif net == "grpc" then
local g = {}
if n.path then g.serviceName = n.path end
ss.grpcSettings = g
elseif net == "http" or net == "h2" then
local h = {}
if n.host then h.host = { n.host } end
if n.path then h.path = n.path end
ss.httpSettings = h
elseif net == "kcp" then
ss.kcpSettings = { header = { type = n.headerType or "none" } }
end
-- 安全层
local security = n.security
if security and security ~= "none" then
ss.security = security
local sni = n.sni or n.servername
if sni then
ss.tlsSettings = { serverName = sni, allowInsecure = false }
end
elseif n.tls and n.tls ~= "none" and n.tls ~= false then
ss.security = "tls"
local sni = n.sni or n.servername
if sni then ss.tlsSettings = { serverName = sni } end
end
return ss
end
-- 单节点 → V2Ray outbound 表
function M.to_outbound(n)
local proto = n.proto or "vmess"
if proto == "ssr" then return nil end -- V2Ray / Xray 不支持 SSR,跳过
local o = {
protocol = (proto == "ss" and "shadowsocks" or proto),
tag = n.name or ((n.server or "") .. ":" .. tostring(n.port or "")),
settings = {},
streamSettings = build_stream_settings(n),
}
local server, port = n.server or "", tonumber(n.port) or 0
if proto == "vmess" then
o.settings.vnext = { {
address = server,
port = port,
users = { {
id = n.uuid or "",
alterId = tonumber(n.alterId or n.aid or 0),
security = n.security or "auto",
} },
} }
elseif proto == "vless" then
local user = { id = n.uuid or "", encryption = "none" }
if n.flow then user.flow = n.flow end
o.settings.vnext = { { address = server, port = port, users = { user } } }
elseif proto == "trojan" then
o.settings.servers = { { address = server, port = port, password = n.password or "" } }
elseif proto == "shadowsocks" or proto == "ss" then
o.settings.servers = { {
address = server,
port = port,
method = n.method or n.cipher or "aes-256-gcm",
password = n.password or "",
} }
else
-- socks / http 等
o.settings.servers = { {
address = server,
port = port,
users = n.username and { { user = n.username, pass = n.password or "" } } or nil,
} }
end
return o
end
function M.generate(nodes)
local outbounds = {}
for _, n in ipairs(nodes or {}) do
if type(n) == "table" then
local o = M.to_outbound(n)
if o then outbounds[#outbounds + 1] = o end
end
end
return util.json_encode({ outbounds = outbounds })
end
return M
@@ -0,0 +1,720 @@
-- parser.lua — 订阅格式解析 → 统一节点模型(纯 Lua)
-- luci-app-substore
local util = require("substore.util")
local node = require("substore.node")
local parser_yaml = require("substore.parser_yaml")
local parser_clash_yaml = require("substore.parser_clash_yaml")
local parser_json_config = require("substore.parser_json_config")
local parser_surge = require("substore.parser_surge")
local M = {}
local SUPPORTED = {
vmess = true, vless = true, trojan = true, ss = true, ssr = true,
hysteria2 = true, tuic = true, wireguard = true,
}
local function split_lines(content)
local out = {}
for line in content:gmatch("[^\r\n]+") do
out[#out + 1] = line
end
return out
end
-- 检测订阅格式:uri / base64 / json / yaml / empty / unknown
function M.detect(content)
content = util.trim(content or "")
content = content:gsub("^\239\187\191", "") -- 去除 UTF-8 BOM(部分机场/CDN 会在开头塞 BOM)
if content == "" then return "empty" end
local stripped = content:gsub("%s+", "")
if stripped:sub(1, 1) == "{" then return "json" end
-- YAML 检测:包含 proxies: 或 outbounds: 键
if content:match("^[%s]*proxies:") or content:match("^[%s]*outbounds:") then
return "yaml"
end
-- 检查内容中是否包含 proxies: 或 outbounds: 行
for line in content:gmatch("[^\r\n]+") do
if line:match("^%s*proxies:%s*$") or line:match("^%s*outbounds:%s*$") then
return "yaml"
end
end
if stripped:find("vmess://", 1, true) or stripped:find("vless://", 1, true)
or stripped:find("trojan://", 1, true) or stripped:find("ssr://", 1, true)
or stripped:find("ss://", 1, true) or content:find("://", 1, true) then
return "uri"
end
if stripped:match("^[A-Za-z0-9+/_%-]*=*$") and #stripped > 10 then
-- 更严格:需含 = / + - _ 之一,或长度为 4 的整数倍(避免把纯字母数字文本误判为 base64)
-- 兼容 base64url(- _ 无 padding):URL-safe 变体也常见于机场订阅
if stripped:find("=", 1, true) or stripped:find("/", 1, true)
or stripped:find("+", 1, true) or stripped:find("-", 1, true)
or stripped:find("_", 1, true) or #stripped % 4 == 0 then
return "base64"
end
-- 纯字母数字且长度非 4 倍数:可能是去掉 padding 的 base64url,尝试解码校验内容
local decoded = util.base64_url_decode(stripped)
if decoded ~= "" and (decoded:find("vmess://", 1, true) or decoded:find("vless://", 1, true)
or decoded:find("trojan://", 1, true) or decoded:find("ss://", 1, true)
or decoded:find("ssr://", 1, true)) then
return "base64"
end
end
if parser_surge.is_config(content) then return "surge" end
return "unknown"
end
-- ---------- 协议解析 ----------
local function parse_ss(body)
-- ss:// 兼容多种变体:base64(method:password@host:port)、method:password@host:port、
-- base64(method:password)@host:port;可选 ?plugin=、#name
local fragment, rest = "", body
local hash = rest:find("#", 1, true)
if hash then
fragment = 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
if hp == "" then return nil, "bad ss" end
local userinfo, hostport
local at = hp:find("@", 1, true)
if at then
userinfo, hostport = hp:sub(1, at - 1), hp:sub(at + 1)
else
-- 整体可能是 base64(method:password@host:port)
local decoded = util.base64_decode(hp)
local d = decoded:find("@", 1, true)
if d then
userinfo, hostport = decoded:sub(1, d - 1), decoded:sub(d + 1)
else
return nil, "bad ss (no @)"
end
end
-- userinfo 可能是 base64(method:password)
if not userinfo:find(":", 1, true) then
userinfo = util.base64_decode(userinfo) or ""
end
local method, password = userinfo:match("^([^:]+):(.*)$")
local host, port = util.split_hostport(hostport)
if not (method and password and host) then return nil, "bad ss" end
local name = fragment ~= "" and fragment or (host .. ":" .. tostring(port or ""))
local out = node.normalize({
proto = "shadowsocks", name = name, server = host, port = tonumber(port),
method = method, password = password, raw = ("ss://" .. body),
})
if query.plugin then out.plugin = query.plugin end
return out
end
-- SSR 参数值可能为 base64url、标准 base64 或纯文本:优先 base64url 解码,失败回退 URL 解码
local function b64u_decode(s)
s = s or ""
local v = util.base64_url_decode(s)
if v ~= "" then return v end
return util.url_decode(s)
end
local function parse_ssr(body)
-- ssr://base64(server:port:protocol:method:obfs:base64(password)/?obfsparam=..&protoparam=..&remarks=..&group=..)
-- 外层为标准 base64;密码字段可为标准/base64url base64;obfsparam/protoparam/remarks/group 为 base64url
if not body or body == "" then return nil, "bad ssr" end
-- 容忍 #fragment 与 URL 转义(部分生成器会在末尾追加)
local rest, frag = body, ""
local hash = rest:find("#", 1, true)
if hash then
frag = util.url_decode(rest:sub(hash + 1))
rest = rest:sub(1, hash - 1)
end
if rest:find("%", 1, true) then rest = util.url_decode(rest) end
local decoded = util.base64_decode(rest)
if decoded == "" then return nil, "bad ssr" end
-- 以首个 '?' 切分主体与参数(密码 base64 可能含 '/',故不能按 '/' 切分)
local main, query = decoded:match("^([^?]*)%?(.*)$")
if not main then main, query = decoded, "" end
main = main:gsub("/+$", "")
local server, port, protocol, method, obfs, pwd_b64 = main:match("^([^:]*):([^:]*):([^:]*):([^:]*):([^:]*):(.*)$")
if not server or server == "" then return nil, "bad ssr" end
local password = util.base64_url_decode(pwd_b64 or "")
if password == "" then password = pwd_b64 or "" end
local params = {}
for k, v in (query or ""):gmatch("([^&=]+)=([^&]*)") do
params[k] = v
end
local name = b64u_decode(params.remarks or "")
if name == "" then name = frag end
if name == "" then name = server .. ":" .. tostring(port or "") end
local out = node.normalize({
proto = "ssr", name = name, server = server, port = tonumber(port),
method = method, password = password, protocol = protocol, obfs = obfs,
raw = ("ssr://" .. body),
})
local op = b64u_decode(params.obfsparam or "")
local pp = b64u_decode(params.protoparam or "")
if op ~= "" then out.obfs_param = op end
if pp ~= "" then out.protocol_param = pp end
local grp = b64u_decode(params.group or "")
if grp ~= "" then out.group = grp end
return out
end
local function parse_vless(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 vless" end
local uuid = hp:sub(1, at - 1)
local host, port = util.split_hostport(hp:sub(at + 1))
local out = node.normalize({
proto = "vless", name = name, server = host, port = tonumber(port), uuid = uuid, raw = uri,
})
if query.type then out.net = query.type end
if query.security then out.security = query.security end
if query.sni then out.sni = query.sni end
if query.fp then out.fp = query.fp end
if query.alpn then out.alpn = query.alpn end
if query.headerType then out.headerType = query.headerType end
return out
end
local function parse_trojan(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 trojan" end
local password = hp:sub(1, at - 1)
local host, port = util.split_hostport(hp:sub(at + 1))
local out = node.normalize({
proto = "trojan", name = name, server = host, port = tonumber(port),
password = password, raw = uri,
})
if query.sni then out.sni = query.sni end
if query.security then out.security = query.security end
if query.alpn then out.alpn = query.alpn end
return out
end
local function parse_vmess(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
-- v2rayN 新格式:vmess://uuid@host:port?type=tcp&security=none#name
if rest:find("@", 1, true) then
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)
local uuid = hp:sub(1, at - 1)
local host, port = util.split_hostport(hp:sub(at + 1))
local out = node.normalize({
proto = "vmess", name = name, server = host, port = tonumber(port),
uuid = uuid, raw = uri,
})
if query.type then out.net = query.type end
if query.security then out.security = query.security end
if query.sni then out.sni = query.sni end
return out
end
-- 经典格式:vmess://base64(json)
local decoded = util.base64_url_decode(rest)
if decoded == "" then return nil, "bad vmess b64" end
local j = util.json_decode(decoded)
if type(j) ~= "table" or not j.add then return nil, "bad vmess json" end
local out = node.normalize({
proto = "vmess",
name = j.ps or (j.add .. ":" .. tostring(j.port)),
server = j.add, port = tonumber(j.port),
uuid = j.id, aid = tonumber(j.aid),
net = j.net, type = j.type, security = j.scy or j.security, tls = j.tls,
raw = uri,
})
return out
end
-- hysteria2://password@host:port/?sni=..&insecure=..#name
local function parse_hysteria2(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 hysteria2" end
local password = util.url_decode(hp:sub(1, at - 1))
local host, port = util.split_hostport(hp:sub(at + 1))
local out = node.normalize({
proto = "hysteria2", name = name, server = host, port = tonumber(port),
password = password, raw = uri,
})
if query.sni then out.sni = query.sni end
if query.insecure ~= nil then out.insecure = query.insecure end
-- 混淆参数回读,保证导出→导入回环不丢字段
if query.obfs then out.obfs = query.obfs end
if query["obfs-password"] then out["obfs-password"] = query["obfs-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
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 tuic" end
local userinfo = util.url_decode(hp:sub(1, at - 1))
local uuid, password = "", userinfo
local cpos = userinfo:find(":", 1, true)
if cpos then
uuid = userinfo:sub(1, cpos - 1)
password = userinfo:sub(cpos + 1)
end
local host, port = util.split_hostport(hp:sub(at + 1))
local out = node.normalize({
proto = "tuic", name = name, server = host, port = tonumber(port),
uuid = uuid, password = password, raw = uri,
})
if query.congestion_control then out.congestion_control = query.congestion_control end
if query.alpn then out.alpn = query.alpn end
if query.sni then out.sni = query.sni end
return out
end
-- wireguard://base64(json)#name —— 本项目自定义 scheme(wireguard 无统一 URI 标准)
local function parse_wireguard(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 decoded = util.base64_decode(rest)
if decoded == "" then return nil, "bad wireguard" end
local j = util.json_decode(decoded)
if type(j) ~= "table" or not j.server or not j.port then return nil, "bad wireguard json" end
local out = node.normalize({
proto = "wireguard", name = name, server = j.server, port = tonumber(j.port),
["private-key"] = j["private-key"] or j.private_key,
["peer-public-key"] = j["peer-public-key"] or j.peer_public_key,
["preshared-key"] = j["preshared-key"] or j.preshared_key,
raw = uri,
})
if j.mtu then out.mtu = tonumber(j.mtu) end
if out.name == "" and j.name then out.name = j.name end
return out
end
-- 解析单条节点 URI,返回节点表或 nil, err
function M.parse_uri(uri)
uri = util.trim(uri)
local proto, body = uri:match("^([%w]+)://(.*)$")
if not proto then return nil, "no scheme" end
proto = proto:lower()
if not SUPPORTED[proto] then return nil, "unsupported proto " .. proto end
if proto == "ss" then return parse_ss(body) end
if proto == "ssr" then return parse_ssr(body) end
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 == "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
return nil, "unsupported"
end
local function parse_lines(lines)
local nodes = {}
for _, line in ipairs(lines) do
line = util.trim(line)
if line ~= "" and line:find("://", 1, true) then
local n = M.parse_uri(line)
if n then nodes[#nodes + 1] = n end
end
end
return nodes
end
local function parse_json_content(content)
local data = util.json_decode(content)
if type(data) ~= "table" then return nil, "JSON 解析失败" end
-- 客户端配置文件分发:sing-box / V2Ray / Clash JSON
if type(data.outbounds) == "table" then
local first = data.outbounds[1]
if type(first) == "table" and first.protocol then
return parser_json_config.parse_v2ray_json(content), nil
end
return parser_json_config.parse_singbox_json(content), nil
end
if type(data.proxies) == "table" then
return parser_json_config.parse_clash_json(content), nil
end
-- 通用节点数组 / 单节点对象
local list
if data[1] then list = data else list = { data } end
local nodes = {}
for _, o in ipairs(list) do
if type(o) == "table" and o.server and o.port then
nodes[#nodes + 1] = node.normalize({
proto = o.proto or "vmess",
name = o.name or (o.server .. ":" .. o.port),
server = o.server, port = tonumber(o.port),
uuid = o.uuid, password = o.password, method = o.method,
net = o.net, security = o.security, sni = o.sni,
raw = o.raw,
})
end
end
return nodes, nil
end
-- 简易 YAML 解析器:处理 Clash YAML proxies 格式
-- 支持基本键值对和列表项,不处理复杂嵌套
local function parse_yaml_content(content)
local raw_nodes = {}
local lines = split_lines(content)
local current_node = nil
local in_proxies = false
local proxies_indent = 0
for i, line in ipairs(lines) do
local trimmed = util.trim(line)
if trimmed == "" or trimmed:sub(1, 1) == "#" then
-- 跳过空行和注释
elseif trimmed:match("^proxies:%s*$") or trimmed:match("^outbounds:%s*$") then
in_proxies = true
proxies_indent = line:match("^%s*") and #line:match("^%s*") or 0
current_node = nil
elseif in_proxies then
-- 检测列表项开始:- name: xxx 或单独的 -
local list_match = line:match("^%s*%-%s*(.*)$")
if list_match then
-- 保存上一个节点
if current_node then
raw_nodes[#raw_nodes + 1] = current_node
end
current_node = {}
-- 检查列表项同一行是否有字段
if list_match:match("^([^:]+):%s*(.+)$") then
local k, v = list_match:match("^([^:]+):%s*(.+)$")
current_node[k] = util.trim(v)
end
else
-- 解析缩进的字段
local indent = line:match("^%s*") and #line:match("^%s*") or 0
if current_node and indent > proxies_indent then
local k, v = trimmed:match("^([^:]+):%s*(.+)$")
if k then
k = util.trim(k)
v = util.trim(v)
-- 去掉引号
v = v:gsub('^["\'](.*)["\']$', '%1')
current_node[k] = v
end
else
-- 遇到缩进减少,结束当前节点
if current_node then
raw_nodes[#raw_nodes + 1] = current_node
current_node = nil
end
in_proxies = false
end
end
end
end
-- 保存最后一个节点
if current_node then
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",
}
-- 转换字段名并归一化
local result = {}
for _, n in ipairs(raw_nodes) do
if not n or not n.server or not n.port then
-- 跳过无效节点
else
local proto = proto_map[n.type or n.proto] or "vmess"
local node_data = {
proto = proto,
name = n.name or n.Name or (n.server .. ":" .. tostring(n.port or "")),
server = n.server,
port = tonumber(n.port),
uuid = n.uuid or n.id,
password = n.password,
method = n.cipher or n.method,
net = n.network or n.net,
security = n.tls or n.security,
sni = n.sni or n.servername,
alterId = tonumber(n.alterId),
}
result[#result + 1] = node.normalize(node_data)
end
end
return result
end
function M.parse_yaml(content)
-- 优先使用完整 Clash YAML 解析器;失败/为空则回退简易解析
local nodes = parser_clash_yaml.parse(content)
if nodes and #nodes > 0 then return nodes end
return parse_yaml_content(content)
end
-- ---------- 局域网订阅链接 ----------
-- 判断主机是否为内网 / 回环 / 链路本地地址(SSRF 防护)
local function is_private_host(host)
if not host then return false end
host = host:lower()
if host == "localhost" or host == "::" then return true end
-- IPv6:去掉方括号
local v6 = host:match("^%[([^%]]+)%]$")
if v6 then host = v6 end
if host:find(":", 1, true) then
local h = host:gsub("^0*", "")
if h == "" or h == "1" then return true end -- :: 或 ::1
return host:match("^::") ~= nil
or host:match("^f[cd]") ~= nil
or host:match("^fe[89ab]") ~= nil
end
local a, b, c, d = host:match("^(%d+)%.(%d+)%.(%d+)%.(%d+)$")
if not a then return false end
a, b, c, d = tonumber(a), tonumber(b), tonumber(c), tonumber(d)
if a == 0 or a == 127 or a == 10 then return true end
if a == 100 and b >= 64 and b <= 127 then return true end -- CGNAT 100.64/10
if a == 192 and b == 168 then return true end
if a == 172 and b >= 16 and b <= 31 then return true end
if a == 169 and b == 254 then return true end
return false
end
-- 检测是否为局域网订阅链接(http/https + 内网主机)
function M.detect_local_link(url)
url = util.trim(url or "")
local scheme, rest = url:match("^(%a+)://(.*)$")
if not scheme then return nil end
scheme = scheme:lower()
if scheme ~= "http" and scheme ~= "https" then return nil end
local authority = rest:match("^([^/]*)") or ""
if authority == "" then return nil end
-- 去除 userinfo@
if authority:find("@", 1, true) then
authority = authority:match("^.*@(.*)$") or ""
end
local host
if authority:sub(1, 1) == "[" then
host = authority:match("^(%[.-%])")
else
host = authority:match("^([^:]+)")
end
if is_private_host(host) then return true end
return nil
end
-- 解析局域网订阅链接,返回 { scheme, host, port, path, target, name, user }(host/port/path 为字符串;缺失为 nil)
function M.parse_local_link(url)
url = util.trim(url or "")
local scheme, rest = url:match("^(%a+)://(.*)$")
if not scheme then return nil end
scheme = scheme:lower()
local authority, remainder = rest:match("^([^/]*)(.*)$")
if authority == nil then authority, remainder = rest, "" end
-- 提取 host 与 port
local host, port
if authority:sub(1, 1) == "[" then
host = authority:match("^%[([^%]]+)%]")
port = authority:match("^%[[^%]]+%]:(%d+)")
else
host = authority:match("^([^:]+)")
port = authority:match("^[^:]+:(%d+)")
end
-- 拆分 path 与 query
local path, query = remainder:match("^([^?]*)(.*)$")
if path == nil then path, query = remainder, "" end
local params = {}
if query and query ~= "" then
for k, v in query:sub(2):gmatch("([^&=]+)=([^&]*)") do
params[k] = util.url_decode(v)
end
end
-- name:优先 ?name=;否则取路径最后一段
local name = params.name
if not name or name == "" then
name = path and path:match("/([^/]+)$")
end
-- user:优先 ?uid= / ?user=;否则取 /<user>/download/ 路径中的用户名
local user = params.uid or params.user
if not user or user == "" then
user = path and path:match("/([^/]+)/download/")
end
return {
scheme = scheme, host = host, port = port, path = path,
target = params.target, name = name, user = user,
}
end
-- 解析订阅内容,返回 { nodes = {...}, format = "..." } 或 nil, err
function M.parse(content)
if not content or content == "" then return { nodes = {}, format = "empty" } end
local format = M.detect(content)
if format == "uri" then
return { nodes = parse_lines(split_lines(content)), format = "uri" }
elseif format == "base64" then
-- 兼容标准 base64 与 base64url(- _ 无 padding):base64_url_decode 两者皆可
local decoded = util.base64_url_decode(content)
if decoded == "" then return nil, "Base64 解码失败" end
return { nodes = parse_lines(split_lines(decoded)), format = "base64" }
elseif format == "json" then
local nodes, err = parse_json_content(content)
if not nodes then return nil, err end
return { nodes = nodes, format = "json" }
elseif format == "yaml" then
local nodes = M.parse_yaml(content)
return { nodes = nodes, format = "yaml" }
elseif format == "surge" then
local nodes = parser_surge.parse(content)
return { nodes = nodes, format = "surge" }
end
return nil, "无法识别的订阅格式"
end
-- 本地订阅解析:支持文本模式和表单模式
function M.parse_local(content, mode)
mode = mode or "text"
if not content or content == "" then return { nodes = {}, format = "empty" } end
if mode == "form" then
-- 表单模式:content 为 JSON 数组
local data = util.json_decode(content)
if type(data) ~= "table" then return nil, "表单数据解析失败" end
local node_mod = require("substore.node")
local nodes = {}
for _, item in ipairs(data) do
if type(item) == "table" then
-- 透传表单全部字段,仅做协议/别名/类型修正,避免白名单丢字段
local n = {}
for k, v in pairs(item) do n[k] = v end
n.proto = item.type or item.proto or "vmess"
n.type = nil
if n.net == nil and n.network ~= nil then n.net = n.network end
n.network = nil
n.port = tonumber(n.port) or n.port
if n.alterId ~= nil then n.alterId = tonumber(n.alterId) or n.alterId end
-- 字符串布尔转布尔值
for _, bk in ipairs({ "udp", "skip-cert-verify", "skip_cert_verify" }) do
if type(n[bk]) == "string" then
n[bk] = (n[bk] == "true" or n[bk] == "1")
end
end
if n["skip-cert-verify"] ~= nil and n.skip_cert_verify == nil then
n.skip_cert_verify = n["skip-cert-verify"]
end
-- shadowsocks 用 method;vmess/ssr 用 cipher,互为别名
if n.method == nil and n.cipher ~= nil then n.method = n.cipher end
if n.cipher == nil and n.method ~= nil then n.cipher = n.method end
-- SSR 参数别名
if n.obfs_param == nil and n["obfs-param"] ~= nil then n.obfs_param = n["obfs-param"] end
if n.protocol_param == nil and n["protocol-param"] ~= nil then n.protocol_param = n["protocol-param"] end
if n.name == nil or n.name == "" then
n.name = (n.server or "") .. ":" .. tostring(n.port or "")
end
nodes[#nodes + 1] = node_mod.normalize(n)
end
end
return { nodes = nodes, format = "local-form" }
end
-- 文本模式:使用通用解析
return M.parse(content)
end
return M
@@ -0,0 +1,197 @@
-- parser_clash_yaml.lua — Clash YAML 解析 → 统一节点模型(纯 Lua 5.1)
-- luci-app-substore
-- 内置极简缩进式 YAML 解析器,处理 Clash proxies / proxy-groups 结构
local util = require("substore.util")
local node = require("substore.node")
local M = {}
-- Clash type → 统一 proto
local TYPE_MAP = {
vmess = "vmess",
vless = "vless",
trojan = "trojan",
ss = "shadowsocks",
shadowsocks = "shadowsocks",
socks5 = "socks",
socks = "socks",
hysteria2 = "hysteria2",
hysteria = "hysteria",
tuic = "tuic",
wireguard = "wireguard",
ssr = "ssr",
http = "http",
}
-- 标量值转换:布尔 / 数字 / 去引号 / 保留字符串
local function scalar(raw)
raw = raw or ""
raw = raw:gsub("%s*$", "")
if raw == "true" or raw == "True" or raw == "TRUE" then return true end
if raw == "false" or raw == "False" or raw == "FALSE" then return false end
if raw == "null" or raw == "~" or raw == "" then return nil end
raw = raw:gsub('^"(.*)"$', "%1"):gsub("^'(.*)'$", "%1")
local n = tonumber(raw)
if n then return n end
return raw
end
-- 预处理内容为 (indent, rest) 列表,跳过空行、注释、文档分隔符
local function preprocess(content)
local items = {}
for l in (content:gsub("\r\n", "\n")):gmatch("[^\n]+") do
local s = l
local ind = 0
while s:sub(1, 1) == " " do ind = ind + 1; s = s:sub(2) end
local t = s:gsub("%s*$", "")
if t ~= "" and not t:match("^#") and t ~= "---" and t ~= "..." then
items[#items + 1] = { ind = ind, rest = t }
end
end
return items
end
local function parse_yaml(content)
local items = preprocess(content)
local pos = 1
local n_items = #items
local read_list
local function read_map(min_indent)
local map = {}
while pos <= n_items do
local it = items[pos]
if it.ind < min_indent then break end
if it.rest == "-" or it.rest:match("^-%s") then break end
local k, v = it.rest:match("^([^:]+):%s*(.*)$")
if not k then
pos = pos + 1
else
k = k:gsub("%s*$", "")
pos = pos + 1
if v == "" or v == "|" or v == ">" then
if pos <= n_items and items[pos].ind > it.ind then
local nxt = items[pos]
if nxt.rest == "-" or nxt.rest:match("^-%s") then
map[k] = read_list(nxt.ind)
else
map[k] = read_map(nxt.ind)
end
else
map[k] = {}
end
else
map[k] = scalar(v)
end
end
end
return map
end
read_list = function(min_indent)
local list = {}
while pos <= n_items do
local it = items[pos]
if it.ind < min_indent then break end
local dash
if it.rest == "-" then dash = "" else dash = it.rest:match("^-%s*(.*)$") end
if dash == nil then break end
pos = pos + 1
local m = nil
-- 流式 JSON 对象:- {"name":"...","type":"vmess",...}(机场 clash 配置常见写法)
if dash:sub(1, 1) == "{" then
local obj = util.json_decode(dash)
if type(obj) == "table" then m = obj end
else
local k, v = dash:match("^([^:]+):%s*(.*)$")
if k and v ~= "" then
m = { [k:gsub("%s*$", "")] = scalar(v) }
end
end
if pos <= n_items and items[pos].ind > it.ind then
local nxt = items[pos]
if nxt.rest == "-" or nxt.rest:match("^-%s") then
list[#list + 1] = m or (dash ~= "" and scalar(dash) or nil)
else
local sub = read_map(nxt.ind)
if m then
for sk, sv in pairs(sub) do m[sk] = sv end
list[#list + 1] = m
else
list[#list + 1] = sub
end
end
elseif m or dash ~= "" then
list[#list + 1] = m or scalar(dash)
end
end
return list
end
local doc = read_map(0)
return doc
end
-- 将单个 Clash proxy 表映射为统一节点模型
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"
local n = {
proto = proto,
name = p.name,
server = p.server,
port = tonumber(p.port),
uuid = p.uuid or p.id,
password = p.password,
method = p.cipher or p.method,
net = p.network or p.net,
sni = p.sni or p.servername,
tls = p.tls,
fp = p.fp,
}
if p.alpn then n.alpn = p.alpn end
if p.udp ~= nil then n.udp = p.udp end
if p["skip-cert-verify"] ~= nil then
local sk = p["skip-cert-verify"]
n["skip-cert-verify"] = sk
n.skip_cert_verify = sk
end
if p.alterId then n.alterId = tonumber(p.alterId) end
if p.security then n.security = p.security end
if p.flow then n.flow = p.flow end
-- ws-opts(Clash 的 ws 传输参数)映射到统一模型的 path / host
if type(p["ws-opts"]) == "table" then
if p["ws-opts"].path then n.path = p["ws-opts"].path end
local wh = p["ws-opts"].headers
if type(wh) == "table" and wh.Host then n.host = wh.Host end
end
-- 保留其余字段(wireguard 的 private-key、peer-public-key 等)
for k, v in pairs(p) do
if n[k] == nil then n[k] = v end
end
return node.normalize(n)
end
-- 解析 Clash YAML,返回统一节点模型列表
function M.parse(content)
if not content or content == "" then return {} end
local doc = parse_yaml(content)
local proxies = doc and doc.proxies
if type(proxies) ~= "table" then return {} end
local nodes = {}
for _, p in ipairs(proxies) do
local n = map_clash_node(p)
if n then nodes[#nodes + 1] = n end
end
return nodes
end
return M
@@ -0,0 +1,269 @@
-- parser_json_config.lua — JSON 配置解析 → 统一节点模型(纯 Lua)
-- luci-app-substore
-- 支持 Sing-box、V2Ray、Clash JSON 配置解析(Lua 5.1 兼容,无 goto)
local util = require("substore.util")
local node = require("substore.node")
local M = {}
-- 协议映射(与 parser_clash_yaml 的 TYPE_MAP 保持一致)
local proto_map = {
vmess = "vmess",
vless = "vless",
trojan = "trojan",
shadowsocks = "shadowsocks",
ss = "shadowsocks",
socks = "socks",
socks5 = "socks",
http = "http",
hysteria2 = "hysteria2",
hysteria = "hysteria",
tuic = "tuic",
wireguard = "wireguard",
ssr = "ssr",
}
local SUPPORTED = {
vmess = true, vless = true, trojan = true, shadowsocks = true,
hysteria2 = true, hysteria = true, tuic = true, wireguard = true,
socks = true, http = true, ssr = true,
}
-- ---------- Sing-box JSON 解析 ----------
function M.parse_singbox_json(content)
if not content or content == "" then return {} end
local data = util.json_decode(content)
if type(data) ~= "table" then return {} end
local nodes = {}
local outbounds = data.outbounds or {}
for _, outbound in ipairs(outbounds) do
if type(outbound) == "table" then
local proto = proto_map[outbound.type] or outbound.type
if proto and SUPPORTED[proto] then
local server = outbound.server
local port = outbound.server_port
if server and port then
local node_data = {
proto = proto,
name = outbound.tag or (server .. ":" .. tostring(port)),
server = server,
port = tonumber(port),
}
if proto == "vmess" or proto == "vless" then
node_data.uuid = outbound.uuid
if outbound.flow then node_data.flow = outbound.flow end
elseif proto == "trojan" then
node_data.password = outbound.password
elseif proto == "shadowsocks" then
node_data.method = outbound.method
node_data.password = outbound.password
elseif proto == "hysteria2" or proto == "hysteria" then
node_data.password = outbound.password or outbound.auth_str or outbound.auth
elseif proto == "tuic" then
node_data.uuid = outbound.uuid
node_data.password = outbound.password
if outbound.congestion_control then node_data.congestion_control = outbound.congestion_control end
elseif proto == "wireguard" then
node_data["private-key"] = outbound["private-key"] or outbound.private_key
node_data["peer-public-key"] = outbound["peer-public-key"] or outbound.peer_public_key
node_data["preshared-key"] = outbound["preshared-key"] or outbound.preshared_key
if outbound.mtu then node_data.mtu = outbound.mtu end
elseif proto == "socks" or proto == "http" then
node_data.username = outbound.username
node_data.password = outbound.password
end
if outbound.tls and type(outbound.tls) == "table" then
if outbound.tls.server_name then
node_data.sni = outbound.tls.server_name
end
if outbound.tls.enabled ~= nil then
node_data.tls = outbound.tls.enabled
end
end
if outbound.security then
node_data.security = outbound.security
end
if outbound.network then
node_data.net = outbound.network
end
nodes[#nodes + 1] = node.normalize(node_data)
end
end
end
end
return nodes
end
-- ---------- V2Ray JSON 解析 ----------
function M.parse_v2ray_json(content)
if not content or content == "" then return {} end
local data = util.json_decode(content)
if type(data) ~= "table" then return {} end
local nodes = {}
local outbounds = data.outbounds or {}
for _, outbound in ipairs(outbounds) do
if type(outbound) == "table" then
local proto = proto_map[outbound.protocol] or outbound.protocol
if proto and SUPPORTED[proto] then
local settings = outbound.settings
if type(settings) == "table" then
local server, port, uuid, password, method, flow, username
if proto == "vmess" or proto == "vless" then
local vnext = settings.vnext
if type(vnext) == "table" and #vnext > 0 then
local sc = vnext[1]
server = sc.address
port = sc.port
if type(sc.users) == "table" and #sc.users > 0 then
local user = sc.users[1]
uuid = user.id
if user.flow then flow = user.flow end
end
end
elseif proto == "trojan" then
local servers = settings.servers
if type(servers) == "table" and #servers > 0 then
server = servers[1].address
port = servers[1].port
password = servers[1].password
end
elseif proto == "shadowsocks" then
local servers = settings.servers
if type(servers) == "table" and #servers > 0 then
server = servers[1].address
port = servers[1].port
method = servers[1].method
password = servers[1].password
end
elseif proto == "socks" or proto == "http" then
local servers = settings.servers
if type(servers) == "table" and #servers > 0 then
server = servers[1].address
port = servers[1].port
if type(servers[1].users) == "table" and #servers[1].users > 0 then
username = servers[1].users[1].user
password = servers[1].users[1].pass
end
end
end
if server and port then
local node_data = {
proto = proto,
name = outbound.tag or (server .. ":" .. tostring(port)),
server = server,
port = tonumber(port),
uuid = uuid,
password = password,
method = method,
flow = flow,
username = username,
}
local ss2 = outbound.streamSettings
if type(ss2) == "table" then
if ss2.network then node_data.net = ss2.network end
if ss2.security then node_data.security = ss2.security end
if ss2.tlsSettings and type(ss2.tlsSettings) == "table" then
if ss2.tlsSettings.serverName then node_data.sni = ss2.tlsSettings.serverName end
end
if ss2.realitySettings and type(ss2.realitySettings) == "table" then
if ss2.realitySettings.serverName then node_data.sni = ss2.realitySettings.serverName end
end
end
nodes[#nodes + 1] = node.normalize(node_data)
end
end
end
end
end
return nodes
end
-- ---------- Clash JSON 解析 ----------
function M.parse_clash_json(content)
if not content or content == "" then return {} end
local data = util.json_decode(content)
if type(data) ~= "table" then return {} end
local nodes = {}
local proxies = data.proxies or {}
for _, proxy in ipairs(proxies) do
if type(proxy) == "table" then
local proto = proto_map[proxy.type] or proxy.type
if proto and SUPPORTED[proto] then
local server = proxy.server
local port = proxy.port
if server and port then
local node_data = {
proto = proto,
name = proxy.name or (server .. ":" .. tostring(port)),
server = server,
port = tonumber(port),
}
if proto == "vmess" or proto == "vless" then
node_data.uuid = proxy.uuid
if proxy.alterId then node_data.alterId = tonumber(proxy.alterId) end
elseif proto == "trojan" then
node_data.password = proxy.password
elseif proto == "shadowsocks" then
node_data.method = proxy.cipher or proxy.method
node_data.password = proxy.password
elseif proto == "hysteria2" or proto == "hysteria" then
node_data.password = proxy.password or proxy["auth-str"] or proxy.auth_str
elseif proto == "tuic" then
node_data.uuid = proxy.uuid
node_data.password = proxy.password
elseif proto == "wireguard" then
node_data["private-key"] = proxy["private-key"] or proxy.private_key
node_data["peer-public-key"] = proxy["peer-public-key"] or proxy.peer_public_key
node_data["preshared-key"] = proxy["preshared-key"] or proxy.preshared_key
if proxy.mtu then node_data.mtu = proxy.mtu end
elseif proto == "socks" or proto == "http" then
node_data.username = proxy.username
node_data.password = proxy.password
elseif proto == "ssr" then
node_data.method = proxy.cipher or proxy.method
node_data.password = proxy.password
node_data.protocol = proxy.protocol
node_data.obfs = proxy.obfs
if proxy["obfs-param"] then node_data.obfs_param = proxy["obfs-param"] end
if proxy["protocol-param"] then node_data.protocol_param = proxy["protocol-param"] end
end
if proxy.network then node_data.net = proxy.network end
if proxy.sni then
node_data.sni = proxy.sni
elseif proxy.servername then
node_data.sni = proxy.servername
end
if proxy.tls then node_data.tls = proxy.tls end
nodes[#nodes + 1] = node.normalize(node_data)
end
end
end
end
return nodes
end
return M
@@ -0,0 +1,131 @@
-- parser_surge.lua — Surge 系 / Loon / QX 配置文件解析 → 统一节点模型(纯 Lua 5.1)
-- luci-app-substore
local util = require("substore.util")
local node = require("substore.node")
local M = {}
-- 解析 Surge 风格行:Name = proto, server, port, k=v, ...
local function parse_surge_line(name, rest)
local parts = {}
for part in rest:gmatch("[^,]+") do
parts[#parts + 1] = util.trim(part)
end
if #parts < 3 then return nil end
local proto = parts[1]:lower()
if proto == "ss" then proto = "shadowsocks" end
local server = parts[2]
local port = tonumber(parts[3])
local kv = {}
for i = 4, #parts do
local k, v = parts[i]:match("^([%w%-]+)%s*=%s*(.*)$")
if k then kv[k] = util.trim(v) end
end
local data = {
proto = proto, name = util.trim(name), server = server, port = port,
}
if proto == "shadowsocks" then
data.method = kv["encrypt-method"] or kv.cipher or kv.method
data.password = kv.password
elseif proto == "vmess" or proto == "vless" then
data.uuid = kv.username or kv.uuid
if kv.flow then data.flow = kv.flow end
elseif proto == "trojan" then
data.password = kv.password
elseif proto == "hysteria2" or proto == "hysteria" then
data.password = kv.password
end
-- 传输/TLS 通用字段
if kv.net or kv.network then data.net = kv.net or kv.network end
if kv["ws"] == "true" then data.net = "ws" end
if kv["ws-path"] then data.path = kv["ws-path"] end
if kv["ws-headers"] and kv["ws-headers"]:match("^Host:") then
data.host = kv["ws-headers"]:match("^Host:%s*(.*)$")
end
if kv.sni then data.sni = kv.sni end
if kv.tls and kv.tls ~= "false" and kv.tls ~= "none" then data.security = "tls" end
if kv["skip-cert-verify"] and kv["skip-cert-verify"] ~= "0" and kv["skip-cert-verify"] ~= "false" then
data["skip-cert-verify"] = true
end
return node.normalize(data)
end
-- 解析 QX server_local 行:proto=server:port, k=v, ..., tag=Name
local function parse_qx_line(content)
local proto, rest = content:match("^([%w_]+)%s*=%s*(.+)$")
if not proto then return nil end
local parts = {}
for part in rest:gmatch("[^,]+") do
parts[#parts + 1] = util.trim(part)
end
if #parts < 1 then return nil end
local hostport = parts[1]
local host, port = util.split_hostport(hostport)
local kv = {}
for i = 2, #parts do
local k, v = parts[i]:match("^([%w%-]+)%s*=%s*(.*)$")
if k then kv[k] = v end
end
local name = kv.tag or (host .. ":" .. tostring(port or ""))
proto = proto:lower()
if proto == "ss" then proto = "shadowsocks" end
local data = { proto = proto, name = name, server = host, port = tonumber(port) }
if proto == "shadowsocks" then
data.method = kv.method
data.password = kv.password
elseif proto == "vmess" or proto == "vless" then
data.uuid = kv.password or kv.uuid
elseif proto == "trojan" then
data.password = kv.password
end
if kv["obfs"] == "ws" then data.net = "ws" end
if kv["obfs-uri"] then data.path = kv["obfs-uri"] end
if kv["obfs-host"] then data.host = kv["obfs-host"] end
if kv["tls-host"] then data.sni = kv["tls-host"] end
if kv["tls-verification"] == "true" or kv["over-tls"] == "true" then data.security = "tls" end
return node.normalize(data)
end
-- 解析客户端配置,返回节点列表(自动识别 Surge/Loon 与 QX 风格)
function M.parse(content)
if not content or content == "" then return {} end
local nodes = {}
local section = ""
for line in content:gmatch("[^\r\n]+") do
local t = util.trim(line)
if t == "" or t:sub(1, 1) == "#" or t:sub(1, 1) == ";" then
-- 跳过空行/注释
elseif t:match("^%[") then
section = t:match("^%[([^%]]+)%]") or ""
section = section:lower()
elseif section == "proxy" then
local name, rest = t:match("^([^=]-)%s*=%s*(.+)$")
if name and rest then
local n = parse_surge_line(name, rest)
if n then nodes[#nodes + 1] = n end
end
elseif section == "server_local" then
local n = parse_qx_line(t)
if n then nodes[#nodes + 1] = n end
end
end
return nodes
end
-- 判断内容是否为客户端配置文件
function M.is_config(content)
content = content or ""
return content:find("%[Proxy%]") ~= nil
or content:find("%[proxy%]") ~= nil
or content:find("%[server_local%]") ~= nil
end
return M
@@ -0,0 +1,187 @@
-- parser_yaml.lua — YAML 解析器:Clash/Sing-box → 统一节点模型(纯 Lua)
-- luci-app-substore
local util = require("substore.util")
local node = require("substore.node")
local M = {}
local proto_map = {
vmess = "vmess",
vless = "vless",
trojan = "trojan",
ss = "shadowsocks",
shadowsocks = "shadowsocks",
ssr = "ssr",
http = "http",
socks5 = "socks5",
}
local function split_lines(content)
local out = {}
for line in content:gmatch("[^\r\n]+") do
out[#out + 1] = line
end
return out
end
local function trim_quotes(v)
return v:gsub('^["\'](.*)["\']$', '%1')
end
local function parse_yaml_section(content, section_key)
local lines = split_lines(content)
local nodes = {}
local current = nil
local in_section = false
local section_indent = 0
local list_indent = nil
for _, line in ipairs(lines) do
local trimmed = util.trim(line)
if trimmed == "" or trimmed:sub(1, 1) == "#" then
-- skip empty and comments
else
local indent = line:match("^%s*") and #line:match("^%s*") or 0
-- section start
if trimmed:match("^" .. section_key .. ":%s*$") then
in_section = true
section_indent = indent
current = nil
list_indent = nil
elseif in_section then
if indent <= section_indent then
in_section = false
if current then
nodes[#nodes + 1] = current
current = nil
end
else
local list_match = line:match("^%s*%-%s*(.*)$")
if list_match then
if current then
nodes[#nodes + 1] = current
end
current = {}
-- inline key:value on same line as -
local k, v = list_match:match("^([^:]+):%s*(.+)$")
if k then
current[util.trim(k)] = trim_quotes(util.trim(v))
end
list_indent = indent
elseif current and indent > (list_indent or section_indent) then
local k, v = trimmed:match("^([^:]+):%s*(.+)$")
if k then
k = util.trim(k)
v = util.trim(v)
v = trim_quotes(v)
-- ignore nested structures with empty value
if v ~= "" then
current[k] = v
end
end
end
end
end
end
end
if current then
nodes[#nodes + 1] = current
end
return nodes
end
local function normalize_clash_node(raw)
if not raw.server or not raw.port then
return nil
end
local proto = proto_map[raw.type or raw.proto] or "vmess"
local node_data = {
proto = proto,
name = raw.name or raw.Name or (raw.server .. ":" .. tostring(raw.port or "")),
server = raw.server,
port = tonumber(raw.port),
uuid = raw.uuid or raw.id,
password = raw.password,
method = raw.cipher or raw.method,
net = raw.network or raw.net,
security = raw.tls or raw.security,
sni = raw.sni or raw.servername,
alterId = tonumber(raw.alterId),
}
return node.normalize(node_data)
end
function M.parse_clash_yaml(content)
local raw_nodes = parse_yaml_section(content, "proxies")
-- also try outbounds for compatibility
if #raw_nodes == 0 then
raw_nodes = parse_yaml_section(content, "outbounds")
end
local result = {}
for _, raw in ipairs(raw_nodes) do
local n = normalize_clash_node(raw)
if n then
result[#result + 1] = n
end
end
return result
end
local function normalize_singbox_node(raw)
if not raw.server or not raw.server_port then
return nil
end
local proto = raw.type
-- map proto names
if proto == "shadowsocks" then proto = "shadowsocks" end
if proto == "ss" then proto = "shadowsocks" end
local node_data = {
proto = proto,
name = raw.tag or (raw.server .. ":" .. tostring(raw.server_port or "")),
server = raw.server,
port = tonumber(raw.server_port),
uuid = raw.uuid,
password = raw.password,
method = raw.method,
security = raw.security,
sni = raw.servername,
net = raw.network,
}
return node.normalize(node_data)
end
function M.parse_singbox_yaml(content)
local raw_nodes = parse_yaml_section(content, "outbounds")
local result = {}
for _, raw in ipairs(raw_nodes) do
local n = normalize_singbox_node(raw)
if n then
result[#result + 1] = n
end
end
return result
end
-- 通用解析:根据 section 自动选择
function M.parse(content)
if not content or content == "" then
return {}
end
if content:find("proxies:", 1, true) then
return M.parse_clash_yaml(content)
elseif content:find("outbounds:", 1, true) then
-- 尝试 sing-box 格式
local nodes = M.parse_singbox_yaml(content)
if #nodes > 0 then
return nodes
end
-- fallback to clash parser
return M.parse_clash_yaml(content)
end
return {}
end
return M
@@ -0,0 +1,153 @@
-- probe.lua — 节点网络探测(Ping / TCPing / URL 测试)(纯 Lua,无 luci.* 依赖)
-- luci-app-substore
-- 依赖 busybox 内置工具:ping、nc、wget、cut(OpenWrt / ImmortalWrt 均内置)
local util = require("substore.util")
local M = {}
M.TIMEOUT = 2 -- 每个目标的超时(秒)
-- shell 引号(%q 双引号转义),配合 safe_host 校验,双重防命令注入
local function q(s) return string.format("%q", s) end
-- 校验目标主机名/IP:仅允许 [A-Za-z0-9._-] 与冒号(IPv6),杜绝命令注入
local function safe_host(host)
return type(host) == "string" and host ~= "" and host:match("^[%w%.%-%:]+$") ~= nil
end
local function safe_port(port)
port = tonumber(port)
if port and port >= 1 and port <= 65535 then return port end
return nil
end
-- 解析 ping 输出的 time=xx.x ms
function M.parse_ping(out)
out = out or ""
local t = out:match("time=([%d%.]+)")
return t and tonumber(t) or nil
end
-- 解析 shell 包装输出的 "起始uptime 结束uptime"(单位秒),返回毫秒延迟
function M.parse_delta(out)
out = util.trim(out or "")
local a, b = out:match("^(%d+%.?%d*)%s+(%d+%.?%d*)$")
if a and b then
local d = (tonumber(b) - tonumber(a)) * 1000
if d >= 0 then return d end
end
return nil
end
-- URL 测试命令与解析(优先 curl 输出 time_total,回退 wget + uptime 差值)
local url_tool
local function detect_url_tool()
if url_tool == nil then
local f = io.popen("command -v curl >/dev/null 2>&1 && echo curl || echo wget")
url_tool = util.trim(f and f:read("*a") or "wget")
if f then f:close() end
end
return url_tool
end
local function build_url_job(url)
if detect_url_tool() == "curl" then
local cmd = "curl -s -o /dev/null --connect-timeout " .. M.TIMEOUT .. " --max-time " .. M.TIMEOUT
.. " -w '%{http_code} %{time_total}' " .. q(url) .. " 2>/dev/null"
return cmd, function(out)
-- http_code=000 表示连接失败(拒绝/超时),其余状态码均说明对端可用 HTTP 通信
local code, t = util.trim(out or ""):match("^(%d+)%s+([%d%.]+)$")
t = tonumber(t)
if code and code ~= "000" and t and t > 0 then return t * 1000 end
return nil
end
end
local cmd = "a=$(cut -d' ' -f1 /proc/uptime); if wget -q -T " .. M.TIMEOUT .. " -O /dev/null " .. q(url)
.. " 2>/dev/null; then b=$(cut -d' ' -f1 /proc/uptime); echo \"$a $b\"; fi"
return cmd, M.parse_delta
end
-- 单节点探测(串行,便于单测)。返回延迟 ms(number)或 nil(失败/超时)
function M.ping(server)
server = util.trim(server or "")
if not safe_host(server) then return nil end
local f = io.popen("ping -c 1 -W " .. M.TIMEOUT .. " " .. q(server) .. " 2>/dev/null")
local out = f and f:read("*a") or ""
if f then f:close() end
return M.parse_ping(out)
end
function M.tcping(server, port)
server = util.trim(server or "")
port = safe_port(port)
if not safe_host(server) or not port then return nil end
local cmd = "a=$(cut -d' ' -f1 /proc/uptime); if nc -z -w " .. M.TIMEOUT .. " " .. q(server) .. " " .. port
.. " >/dev/null 2>&1; then b=$(cut -d' ' -f1 /proc/uptime); echo \"$a $b\"; fi"
local f = io.popen(cmd)
local out = f and f:read("*a") or ""
if f then f:close() end
return M.parse_delta(out)
end
function M.url_test(server, port)
server = util.trim(server or "")
port = safe_port(port)
if not safe_host(server) or not port then return nil end
local url = "http://" .. server .. ":" .. port .. "/"
local cmd, parse = build_url_job(url)
local f = io.popen(cmd)
local out = f and f:read("*a") or ""
if f then f:close() end
return parse(out)
end
-- 为单个节点构造探测任务:返回 cmd, parse 或 nil
local function build_job(mode, server, port)
server = util.trim(server or "")
if not safe_host(server) then return nil end
if mode == "ping" then
return "ping -c 1 -W " .. M.TIMEOUT .. " " .. q(server) .. " 2>/dev/null", M.parse_ping
elseif mode == "tcping" then
port = safe_port(port)
if not port then return nil end
local cmd = "a=$(cut -d' ' -f1 /proc/uptime); if nc -z -w " .. M.TIMEOUT .. " " .. q(server) .. " " .. port
.. " >/dev/null 2>&1; then b=$(cut -d' ' -f1 /proc/uptime); echo \"$a $b\"; fi"
return cmd, M.parse_delta
elseif mode == "url" then
port = safe_port(port)
if not port then return nil end
return build_url_job("http://" .. server .. ":" .. port .. "/")
end
return nil
end
-- 批量探测:mode = "ping" | "tcping" | "url"
-- 并行启动所有子进程再统一读取,总耗时≈单节点超时,而非节点数×超时。
-- 返回结果数组 { name=, server=, port=, latency=number|nil }
function M.probe(nodes, mode)
local jobs = {}
for _, n in ipairs(nodes or {}) do
local cmd, parse = build_job(mode, n.server, n.port)
local f = cmd and io.popen(cmd) or nil
jobs[#jobs + 1] = {
f = f, parse = parse,
name = n.name or "", server = n.server or "", port = n.port or "",
}
end
local out = {}
for _, j in ipairs(jobs) do
local latency
if j.f then
local raw = j.f:read("*a") or ""
j.f:close()
latency = j.parse and j.parse(raw) or nil
end
out[#out + 1] = {
name = j.name, server = j.server, port = j.port, latency = latency,
}
end
return out
end
return M
@@ -0,0 +1,381 @@
-- util.lua — 通用工具(纯 Lua 5.1,无外部依赖)
-- luci-app-substore
local M = {}
local B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
function M.trim(s)
return (s:gsub("^%s+", ""):gsub("%s+$", ""))
end
-- 安全 require:模块不存在时返回 nil 而不报错
function M.try_require(name)
local ok, mod = pcall(require, name)
if ok and mod then return mod end
return nil
end
-- ---------- Base64 ----------
function M.base64_encode(s)
if type(s) ~= "string" then return "" end
local out = {}
for i = 1, #s, 3 do
local b1 = s:byte(i)
local b2 = s:byte(i + 1)
local b3 = s:byte(i + 2)
local n = b1 * 65536 + (b2 or 0) * 256 + (b3 or 0)
out[#out + 1] = B64:sub(math.floor(n / 262144) % 64 + 1, math.floor(n / 262144) % 64 + 1)
out[#out + 1] = B64:sub(math.floor(n / 4096) % 64 + 1, math.floor(n / 4096) % 64 + 1)
out[#out + 1] = b2 and B64:sub(math.floor(n / 64) % 64 + 1, math.floor(n / 64) % 64 + 1) or "="
out[#out + 1] = b3 and B64:sub(n % 64 + 1, n % 64 + 1) or "="
end
return table.concat(out)
end
function M.base64_decode(s)
if type(s) ~= "string" then return "" end
s = s:gsub("[^%w%+/=]", "")
local out = {}
local n, bits = 0, 0
for i = 1, #s do
local c = s:sub(i, i)
if c == "=" then break end
local v = B64:find(c, 1, true)
if v then
n = n * 64 + (v - 1)
bits = bits + 6
while bits >= 8 do
bits = bits - 8
n = math.floor(n)
local byte = math.floor(n / (2 ^ bits)) % 256
out[#out + 1] = string.char(byte)
n = n % (2 ^ bits)
end
end
end
return table.concat(out)
end
-- URL-safe Base64(RFC 4648 base64url):- _ 代替 + /,无 padding
function M.base64_url_encode(s)
s = s or ""
return (M.base64_encode(s):gsub("+", "-"):gsub("/", "_"):gsub("=+$", ""))
end
function M.base64_url_decode(s)
if type(s) ~= "string" then return "" end
s = s:gsub("-", "+"):gsub("_", "/")
return M.base64_decode(s)
end
-- 生成随机十六进制 token(用于下载链接访问控制)
function M.rnd_hex(len)
len = len or 16
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)
end
return table.concat(out)
end
-- ---------- URL ----------
function M.url_decode(s)
if type(s) ~= "string" then return "" end
return s:gsub("%%(%x%x)", function(h) return string.char(tonumber(h, 16)) end)
end
-- 从 query string 提取字段(接受 URL 片段,如 "…?a=1&b=2#frag")
function M.url_extract(s, field)
if type(s) ~= "string" then return nil end
local q = s:match("[^#?]*%?([^#]*)")
if not q then return nil end
for k, v in q:gmatch("([^&=]+)=([^&]*)") do
if k == field then return M.url_decode(v) end
end
return nil
end
-- 拆分 host:port,支持 [ipv6]:port
function M.split_hostport(s)
if type(s) ~= "string" then return nil, nil end
s = M.trim(s)
-- 去掉可能尾随的路径(如 "host:port/path")
s = s:match("^([^/]*)") or s
if s == "" then return nil, nil end
if s:sub(1, 1) == "[" then
local close = s:find("]", 1, true)
if not close then return nil, nil end
local host = s:sub(2, close - 1)
local port = s:sub(close + 1):match("^:(%d+)$")
return host, port
else
local host, port = s:match("^([^:]+):(%d+)$")
if host then return host, port end
return s, nil
end
end
-- ---------- JSON ----------
local function utf8_char(cp)
if cp < 0x80 then
return string.char(cp)
elseif cp < 0x800 then
return string.char(0xC0 + math.floor(cp / 0x40), 0x80 + cp % 0x40)
elseif cp < 0x10000 then
return string.char(0xE0 + math.floor(cp / 0x1000),
0x80 + math.floor(cp / 0x40) % 0x40, 0x80 + cp % 0x40)
else
return string.char(0xF0 + math.floor(cp / 0x40000),
0x80 + math.floor(cp / 0x1000) % 0x40,
0x80 + math.floor(cp / 0x40) % 0x40, 0x80 + cp % 0x40)
end
end
local function is_array(t)
local i = 0
for k in pairs(t) do
i = i + 1
if t[i] == nil then return false end
end
return i == #t
end
-- JSON null 占位符:仅在数组中用于保留位置(对象中的 null 仍解码为 nil)
local JSON_NULL = {}
function M.json_encode(v)
local function enc(v)
local t = type(v)
if v == nil then
return "null"
elseif t == "string" then
return '"' .. v:gsub('[%z\1-\31\\"]', function(c)
if c == '"' then return '\\"'
elseif c == "\\" then return "\\\\"
elseif c == "\n" then return "\\n"
elseif c == "\r" then return "\\r"
elseif c == "\t" then return "\\t"
elseif c == "\b" then return "\\b"
elseif c == "\f" then return "\\f"
else return string.format("\\u%04x", c:byte()) end
end) .. '"'
elseif t == "number" then
if v ~= v then return "null" end
return string.format("%.14g", v)
elseif t == "boolean" then
return v and "true" or "false"
elseif t == "table" then
if is_array(v) then
local a = {}
for i = 1, #v do a[#a + 1] = enc(v[i]) end
return "[" .. table.concat(a, ",") .. "]"
else
local a = {}
for k, val in pairs(v) do
a[#a + 1] = enc(tostring(k)) .. ":" .. enc(val)
end
return "{" .. table.concat(a, ",") .. "}"
end
end
return "null"
end
return enc(v)
end
function M.json_decode(s)
if type(s) ~= "string" then return nil, "not a string" end
local i = 1
local len = #s
local function skip_ws()
while true do
local c = s:sub(i, i)
if c == " " or c == "\t" or c == "\n" or c == "\r" then i = i + 1 else break end
end
end
local function parse()
skip_ws()
if i > len then return nil, "unexpected end" end
local c = s:sub(i, i)
if c == "{" then
i = i + 1
local obj = {}
skip_ws()
if s:sub(i, i) == "}" then i = i + 1 return obj end
while true do
skip_ws()
local k, ke = parse()
if not k or type(k) ~= "string" then return nil, "expected string key" end
skip_ws()
if s:sub(i, i) ~= ":" then return nil, "expected ':'" end
i = i + 1
local val = parse()
obj[k] = val
skip_ws()
local cc = s:sub(i, i)
if cc == "," then
i = i + 1
elseif cc == "}" then
i = i + 1
return obj
else
return nil, "expected ',' or '}'"
end
end
elseif c == "[" then
i = i + 1
local arr = {}
skip_ws()
if s:sub(i, i) == "]" then i = i + 1 return arr end
while true do
local val = parse()
if val == nil then val = JSON_NULL end
arr[#arr + 1] = val
skip_ws()
local cc = s:sub(i, i)
if cc == "," then
i = i + 1
elseif cc == "]" then
i = i + 1
return arr
else
return nil, "expected ',' or ']'"
end
end
elseif c == '"' then
i = i + 1
local out = {}
while true do
local ch = s:sub(i, i)
if ch == "" then return nil, "unterminated string" end
if ch == '"' then i = i + 1 return table.concat(out) end
if ch == "\\" then
i = i + 1
local e = s:sub(i, i)
i = i + 1
local map = { ['"'] = '"', ["\\"] = "\\", ["/"] = "/",
b = "\b", f = "\f", n = "\n", r = "\r", t = "\t" }
if e == "u" then
local hex = s:sub(i, i + 3)
i = i + 4
local cp = tonumber(hex, 16)
if not cp then return nil, "bad unicode" end
if cp >= 0xD800 and cp <= 0xDBFF then
if s:sub(i, i) == "\\" and s:sub(i + 1, i + 1) == "u" then
local hex2 = s:sub(i + 2, i + 5)
local cp2 = tonumber(hex2, 16)
if cp2 and cp2 >= 0xDC00 and cp2 <= 0xDFFF then
i = i + 6
cp = 0x10000 + (cp - 0xD800) * 0x400 + (cp2 - 0xDC00)
end
end
end
out[#out + 1] = utf8_char(cp)
else
out[#out + 1] = map[e] or e
end
else
out[#out + 1] = ch
i = i + 1
end
end
elseif c == "t" and s:sub(i, i + 3) == "true" then
i = i + 4
return true
elseif c == "f" and s:sub(i, i + 4) == "false" then
i = i + 5
return false
elseif c == "n" and s:sub(i, i + 3) == "null" then
i = i + 4
return nil
elseif c:match("[%-%d]") then
local num = s:match("^-?%d+%.?%d*[eE]?[%+%-]?%d*", i)
if not num or num == "" then return nil, "bad number" end
i = i + #num
local n = tonumber(num)
if not n then return nil, "bad number value" end
return n
else
return nil, "unexpected char " .. c
end
end
local v = parse()
return v
end
-- ---------- 文件 ----------
function M.read_file(path)
local f, e = io.open(path, "rb")
if not f then return nil, e end
local data = f:read("*a")
f:close()
return data
end
function M.file_size(path)
local f = io.open(path, "rb")
if not f then return 0 end
local data = f:read("*a")
f:close()
return #data
end
-- 原子写:先写临时文件再重命名,避免写一半损坏
function M.atomic_write(path, content)
local tmp = path .. ".tmp"
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
os.remove(tmp)
return false, e2
end
return true
end
function M.ensure_dir(path)
os.execute("mkdir -p " .. string.format("%q", path))
end
-- ---------- 人性化格式 ----------
-- 字节数 → 可读字符串(B/K/M/G/T),如 20G、512M
function M.human_bytes(n)
n = tonumber(n) or 0
n = math.floor(n + 0.5)
if n <= 0 then return "0B" end
local units = { "B", "K", "M", "G", "T" }
local i, v = 1, n
while v >= 1024 and i < #units do
v = v / 1024
i = i + 1
end
local s = string.format("%.1f", v):gsub("%.0$", "")
return s .. units[i]
end
-- 秒数 → 可读时长(天/小时/分钟),如 10天、8小时
function M.human_duration(secs)
secs = tonumber(secs) or 0
if secs <= 0 then return "已过期" end
local d = secs / 86400
if d >= 1 then return string.format("%d天", math.floor(d)) end
local h = secs / 3600
if h >= 1 then return string.format("%d小时", math.floor(h)) end
local m = secs / 60
if m >= 1 then return string.format("%d分钟", math.floor(m)) end
return "不足1分钟"
end
return M
@@ -0,0 +1,138 @@
/* nodeform.js — 节点动态字段表单(local_form.htm / node_edit.htm 共用)
* 依赖页面在引入本文件前定义:
* var FIELD_LABELS = { "name": "...", "group": "...", ... }; // 字段标签(模板用 <%:...%> 渲染)
* var NODEFORM_I18N = { node: "...", defaultOpt: "..." }; // 界面文本(同上)
* 提供:SUBSTORE_PROTOS / PROTO_FIELDS / renderFields / collectNodes / addNodeRow
*/
var SUBSTORE_PROTOS = ["vmess","vless","trojan","shadowsocks","ssr","hysteria2","tuic","wireguard"];
var PROTO_FIELDS = {
ssr: ["server","port","password","cipher","protocol","obfs","obfs-param","protocol-param","udp"],
vmess: ["server","port","uuid","alterId","cipher","net","headerType","path","host","sni","tls","udp","skip-cert-verify"],
vless: ["server","port","uuid","security","flow","net","headerType","path","host","sni","udp","skip-cert-verify"],
trojan: ["server","port","password","sni","net","headerType","path","host","udp","skip-cert-verify"],
shadowsocks: ["server","port","password","method","headerType","udp"],
hysteria2: ["server","port","password","sni","obfs","obfs-password","skip-cert-verify"],
tuic: ["server","port","uuid","password","sni","udp","skip-cert-verify"],
wireguard: ["server","port","private-key","peer-public-key","udp"]
};
// 枚举字段用下拉框;键为 "协议.字段" 优先,退化为通用 "字段"
// 选项集合以模型/输出模块实际支持的值为准(见 output_singbox.build_transport / output_uri)
var SELECT_FIELDS = {
"net": ["tcp","ws","h2","grpc"],
"headerType": ["none","http"],
"cipher": ["auto","aes-128-gcm","chacha20-poly1305","none","zero"],
"method": ["aes-128-gcm","aes-256-gcm","chacha20-ietf-poly1305","xchacha20-ietf-poly1305","aes-128-cfb","aes-256-cfb","rc4-md5","chacha20-ietf","2022-blake3-aes-128-gcm","2022-blake3-aes-256-gcm","2022-blake3-chacha20-poly1305"],
"security": ["none","tls","reality"],
"flow": ["","xtls-rprx-vision"],
"tls": ["","tls"],
"hysteria2.obfs": ["","salamander"]
};
// 布尔字段用下拉框:默认(不提交)/ true / false
var BOOL_FIELDS = { "udp": true, "skip-cert-verify": true };
function nodeformLabel(k) {
return (typeof FIELD_LABELS === "object" && FIELD_LABELS && FIELD_LABELS[k]) || k;
}
function nodeformT(k) {
return (typeof NODEFORM_I18N === "object" && NODEFORM_I18N && NODEFORM_I18N[k]) || k;
}
function fieldRow(k, proto) {
var label = nodeformLabel(k);
var input;
var opts = SELECT_FIELDS[proto + "." + k] || SELECT_FIELDS[k];
if (opts) {
input = '<select data-k="' + k + '">';
for (var i = 0; i < opts.length; i++) {
var v = opts[i];
input += '<option value="' + v + '">' + (v === "" ? nodeformT("defaultOpt") : v) + '</option>';
}
input += '</select>';
} else if (BOOL_FIELDS[k]) {
input = '<select data-k="' + k + '">' +
'<option value="">' + nodeformT("defaultOpt") + '</option>' +
'<option value="true">true</option>' +
'<option value="false">false</option>' +
'</select>';
} else {
input = '<input type="text" data-k="' + k + '" style="width:50%"/>';
}
return '<label style="display:inline-block;min-width:10em">' + label + '</label>' + input + '<br/>';
}
function nodeTemplate(idx) {
var opts = "";
for (var i = 0; i < SUBSTORE_PROTOS.length; i++) {
opts += '<option value="' + SUBSTORE_PROTOS[i] + '">' + SUBSTORE_PROTOS[i] + '</option>';
}
return '<div class="node_row" style="border:1px solid #ddd;padding:0.5em;margin:0.5em 0">' +
'<div style="margin-bottom:0.25em"><strong>' + nodeformT("node") + ' ' + (idx + 1) + '</strong> ' +
'<button type="button" class="cbi-button cbi-button-reset node_remove" onclick="this.parentNode.parentNode.remove()">×</button></div>' +
'<label style="display:inline-block;min-width:10em">' + nodeformLabel("name") + '</label>' +
'<input type="text" data-k="name" style="width:50%"/><br/>' +
'<label style="display:inline-block;min-width:10em">' + nodeformLabel("group") + '</label>' +
'<input type="text" data-k="group" style="width:50%"/><br/>' +
'<label style="display:inline-block;min-width:10em">' + nodeformLabel("protocol") + '</label>' +
'<select data-k="type" onchange="renderFields(this)">' + opts + '</select><br/>' +
'<div class="fields"></div>' +
'</div>';
}
function renderFields(sel, data) {
var row = sel.parentNode;
while (row && row.className.indexOf("node_row") < 0) row = row.parentNode;
if (!row) return;
var fieldsDiv = row.querySelector(".fields");
var proto = sel.value;
var keys = PROTO_FIELDS[proto] || [];
var html = "";
for (var i = 0; i < keys.length; i++) html += fieldRow(keys[i], proto);
fieldsDiv.innerHTML = html;
if (data) {
for (var j = 0; j < keys.length; j++) {
var el = fieldsDiv.querySelector('[data-k="' + keys[j] + '"]');
if (el && data[keys[j]] !== undefined && data[keys[j]] !== null) el.value = String(data[keys[j]]);
}
}
}
function collectNodes() {
var rows = document.querySelectorAll(".node_row");
var out = [];
for (var i = 0; i < rows.length; i++) {
var obj = {};
var els = rows[i].querySelectorAll("[data-k]");
for (var j = 0; j < els.length; j++) {
var k = els[j].getAttribute("data-k");
var v = (els[j].value || "").replace(/^\s+|\s+$/g, "");
if (v !== "") obj[k] = v;
}
if (obj.name || obj.server) out.push(obj);
}
return out;
}
function addNodeRow(data) {
var list = document.getElementById("nodes_list");
var div = document.createElement("div");
div.innerHTML = nodeTemplate(list.children.length);
var row = div.firstChild;
list.appendChild(row);
var sel = row.querySelector('select[data-k="type"]');
if (data && (data.type || data.proto)) sel.value = data.type || data.proto;
if (data && data.name) {
var nameEl = row.querySelector('[data-k="name"]');
if (nameEl) nameEl.value = data.name;
}
if (data && data.group) {
var groupEl = row.querySelector('[data-k="group"]');
if (groupEl) groupEl.value = data.group;
}
renderFields(sel, data);
return row;
}
@@ -0,0 +1,176 @@
-- converter_protocol_test.lua — 协议转换单元测试
-- 用法:lua5.1 tests/converter_protocol_test.lua
package.path = "./root/usr/share/?.lua;" .. package.path
local converter = require("substore.converter")
local passed, failed = 0, 0
local function check(name, cond)
if cond then
passed = passed + 1
print("PASS " .. name)
else
failed = failed + 1
print("FAIL " .. name)
end
end
local function eq(a, b)
if type(a) ~= type(b) then return false end
if type(a) == "table" then
for k, v in pairs(a) do if not eq(v, b[k]) then return false end end
for k in pairs(b) do if a[k] == nil then return false end end
return true
end
return a == b
end
-- ---------- 基础功能测试 ----------
check("module exists", converter ~= nil)
check("convert_protocol method exists", type(converter.convert_protocol) == "function")
-- ---------- vmess 转换测试 ----------
local vmess_node = {
proto = "vmess",
server = "1.1.1.1",
port = 443,
uuid = "test-uuid-vmess",
alterId = 0,
cipher = "auto",
network = "ws",
security = "tls",
sni = "example.com",
path = "/ws",
host = "example.com",
}
-- vmess → vless
local vless_converted = converter.convert_protocol(vmess_node, "vless")
check("vmess to vless proto", vless_converted and vless_converted.proto == "vless")
check("vmess to vless uuid", vless_converted and vless_converted.uuid == "test-uuid-vmess")
check("vmess to vless server", vless_converted and vless_converted.server == "1.1.1.1")
check("vmess to vless port", vless_converted and vless_converted.port == 443)
check("vmess to vless network", vless_converted and vless_converted.network == "ws")
check("vmess to vless security", vless_converted and vless_converted.security == "tls")
check("vmess to vless sni", vless_converted and vless_converted.sni == "example.com")
check("vmess to vless path", vless_converted and vless_converted.path == "/ws")
check("vmess to vless host", vless_converted and vless_converted.host == "example.com")
check("vmess to vless no alterId", vless_converted and vless_converted.alterId == nil)
check("vmess to vless no cipher", vless_converted and vless_converted.cipher == nil)
-- vmess → trojan
local trojan_converted = converter.convert_protocol(vmess_node, "trojan")
check("vmess to trojan proto", trojan_converted and trojan_converted.proto == "trojan")
check("vmess to trojan password", trojan_converted and trojan_converted.password == "test-uuid-vmess")
check("vmess to trojan server", trojan_converted and trojan_converted.server == "1.1.1.1")
check("vmess to trojan network", trojan_converted and trojan_converted.network == "ws")
-- vmess → shadowsocks (不支持)
local ss_converted = converter.convert_protocol(vmess_node, "shadowsocks")
check("vmess to ss unsupported", ss_converted == nil)
-- ---------- vless 转换测试 ----------
local vless_node = {
proto = "vless",
server = "2.2.2.2",
port = 8443,
uuid = "test-uuid-vless",
security = "tls",
sni = "vless.example.com",
network = "grpc",
}
-- vless → vmess
local vmess_from_vless = converter.convert_protocol(vless_node, "vmess")
check("vless to vmess proto", vmess_from_vless and vmess_from_vless.proto == "vmess")
check("vless to vmess uuid", vmess_from_vless and vmess_from_vless.uuid == "test-uuid-vless")
check("vless to vmess server", vmess_from_vless and vmess_from_vless.server == "2.2.2.2")
check("vless to vmess network", vmess_from_vless and vmess_from_vless.network == "grpc")
-- vless → trojan
local trojan_from_vless = converter.convert_protocol(vless_node, "trojan")
check("vless to trojan proto", trojan_from_vless and trojan_from_vless.proto == "trojan")
check("vless to trojan password", trojan_from_vless and trojan_from_vless.password == "test-uuid-vless")
check("vless to trojan sni", trojan_from_vless and trojan_from_vless.sni == "vless.example.com")
-- ---------- trojan 转换测试 ----------
local trojan_node = {
proto = "trojan",
server = "3.3.3.3",
port = 443,
password = "trojan-pass",
sni = "example.com",
network = "tcp",
}
-- trojan → vmess (password → uuid 生成)
local vmess_from_trojan = converter.convert_protocol(trojan_node, "vmess")
check("trojan to vmess proto", vmess_from_trojan and vmess_from_trojan.proto == "vmess")
check("trojan to vmess uuid exists", vmess_from_trojan and vmess_from_trojan.uuid ~= nil)
check("trojan to vmess uuid not password", vmess_from_trojan and vmess_from_trojan.uuid ~= "trojan-pass")
check("trojan to vmess server", vmess_from_trojan and vmess_from_trojan.server == "3.3.3.3")
-- trojan → vless
local vless_from_trojan = converter.convert_protocol(trojan_node, "vless")
check("trojan to vless proto", vless_from_trojan and vless_from_trojan.proto == "vless")
check("trojan to vless uuid exists", vless_from_trojan and vless_from_trojan.uuid ~= nil)
check("trojan to vless sni", vless_from_trojan and vless_from_trojan.sni == "example.com")
-- ---------- shadowsocks 转换测试 (不支持) ----------
local ss_node = {
proto = "shadowsocks",
server = "4.4.4.4",
port = 8388,
method = "aes-256-gcm",
password = "ss-pass",
}
local vmess_from_ss = converter.convert_protocol(ss_node, "vmess")
check("ss to vmess unsupported", vmess_from_ss == nil)
local vless_from_ss = converter.convert_protocol(ss_node, "vless")
check("ss to vless unsupported", vless_from_ss == nil)
-- ---------- 特殊字段转换测试 ----------
local vmess_with_special = {
proto = "vmess",
server = "5.5.5.5",
port = 443,
uuid = "uuid-special",
network = "h2",
security = "reality",
sni = "sni.example.com",
alpn = "h2,http/1.1",
fp = "chrome",
headerType = "none",
}
local vless_special = converter.convert_protocol(vmess_with_special, "vless")
check("network h2 preserved", vless_special and vless_special.network == "h2")
check("security reality preserved", vless_special and vless_special.security == "reality")
check("sni preserved", vless_special and vless_special.sni == "sni.example.com")
check("alpn preserved", vless_special and vless_special.alpn == "h2,http/1.1")
check("fp preserved", vless_special and vless_special.fp == "chrome")
-- ---------- 字段缺失处理测试 ----------
local vmess_minimal = {
proto = "vmess",
server = "6.6.6.6",
port = 443,
uuid = "uuid-minimal",
}
local vless_minimal = converter.convert_protocol(vmess_minimal, "vless")
check("minimal vmess to vless works", vless_minimal ~= nil)
check("minimal server preserved", vless_minimal and vless_minimal.server == "6.6.6.6")
check("minimal uuid preserved", vless_minimal and vless_minimal.uuid == "uuid-minimal")
-- ---------- 不支持的转换 ----------
local result = converter.convert_protocol(vmess_node, "unsupported_proto")
check("unsupported target returns nil", result == nil)
-- ---------- 结果 ----------
print(string.format("\n%d passed, %d failed", passed, failed))
os.exit(failed == 0 and 0 or 1)
@@ -0,0 +1,115 @@
-- converter_template_test.lua — URL 模板渲染单元测试
-- 用法:lua5.1 tests/converter_template_test.lua
package.path = "./root/usr/share/?.lua;" .. package.path
local converter = require("substore.converter")
local passed, failed = 0, 0
local function check(name, cond)
if cond then
passed = passed + 1
print("PASS " .. name)
else
failed = failed + 1
print("FAIL " .. name)
end
end
-- ---------- 基础功能测试 ----------
check("module exists", converter ~= nil)
check("render_template method exists", type(converter.render_template) == "function")
check("render_url_template method exists", type(converter.render_url_template) == "function")
-- ---------- 变量替换测试 ----------
local node1 = { name = "TestNode", server = "1.1.1.1", port = 443, proto = "vmess", uuid = "abc-123", password = "pass" }
check("render_template variable name", converter.render_template("{name}", node1) == "TestNode")
check("render_template variable server", converter.render_template("{server}", node1) == "1.1.1.1")
check("render_template variable port", converter.render_template("{port}", node1) == "443")
check("render_template variable proto", converter.render_template("{proto}", node1) == "vmess")
check("render_template variable uuid", converter.render_template("{uuid}", node1) == "abc-123")
check("render_template variable password", converter.render_template("{password}", node1) == "pass")
check("render_template multiple vars", converter.render_template("{name}@{server}:{port}", node1) == "TestNode@1.1.1.1:443")
-- ---------- 函数调用测试 ----------
check("render_template upper", converter.render_template("{upper:name}", node1) == "TESTNODE")
check("render_template lower", converter.render_template("{lower:server}", node1) == "1.1.1.1")
check("render_template upper server", converter.render_template("{upper:server}", node1) == "1.1.1.1")
check("render_template lower proto", converter.render_template("{lower:proto}", node1) == "vmess")
-- ---------- 默认值测试 ----------
check("render_template default missing", converter.render_template("{name|DefaultName}", {}) == "DefaultName")
check("render_template default present", converter.render_template("{name|DefaultName}", node1) == "TestNode")
check("render_template default empty", converter.render_template("{missing|fallback}", node1) == "fallback")
-- ---------- 条件渲染测试 ----------
check("render_template conditional true", converter.render_template("{if:proto?vmess:vless}", node1) == "vmess")
check("render_template conditional false", converter.render_template("{if:missing?yes:no}", node1) == "no")
check("render_template conditional with value", converter.render_template("{if:server?has-server:no-server}", node1) == "has-server")
-- ---------- 批量渲染测试 ----------
local nodes = {
{ name = "Node1", server = "1.1.1.1", port = 443 },
{ name = "Node2", server = "2.2.2.2", port = 8443 },
}
local urls = converter.render_url_template(nodes, "{name}-{server}:{port}")
check("render_url_template count", type(urls) == "table" and #urls == 2)
check("render_url_template first", urls[1] == "Node1-1.1.1.1:443")
check("render_url_template second", urls[2] == "Node2-2.2.2.2:8443")
-- ---------- 协议 URL 模板测试 ----------
local vmess_node = {
name = "VMessTest",
server = "1.1.1.1",
port = 443,
uuid = "uuid-vmess",
security = "tls",
}
local vless_node = {
name = "VLESS Test",
server = "2.2.2.2",
port = 8443,
uuid = "uuid-vless",
}
local trojan_node = {
name = "TrojanTest",
server = "3.3.3.3",
port = 443,
password = "trojan-pass",
}
local ss_node = {
name = "SSTest",
server = "4.4.4.4",
port = 8388,
method = "aes-256-gcm",
password = "ss-pass",
}
-- 检查模板常量存在
check("vmess template exists", converter.URL_TEMPLATES and converter.URL_TEMPLATES.vmess ~= nil)
check("vless template exists", converter.URL_TEMPLATES and converter.URL_TEMPLATES.vless ~= nil)
check("trojan template exists", converter.URL_TEMPLATES and converter.URL_TEMPLATES.trojan ~= nil)
check("ss template exists", converter.URL_TEMPLATES and converter.URL_TEMPLATES.ss ~= nil)
-- 使用模板渲染
local vmess_url = converter.render_template(converter.URL_TEMPLATES.vmess, vmess_node)
check("vmess url contains uuid", vmess_url:find("uuid-vmess", 1, true) ~= nil)
check("vmess url contains server", vmess_url:find("1.1.1.1") ~= nil)
check("vmess url contains name", vmess_url:find("VMessTest") ~= nil)
local vless_url = converter.render_template(converter.URL_TEMPLATES.vless, vless_node)
check("vless url contains uuid", vless_url:find("uuid-vless", 1, true) ~= nil)
check("vless url contains server", vless_url:find("2.2.2.2") ~= nil)
local trojan_url = converter.render_template(converter.URL_TEMPLATES.trojan, trojan_node)
check("trojan url contains password", trojan_url:find("trojan-pass", 1, true) ~= nil)
check("trojan url contains server", trojan_url:find("3.3.3.3") ~= nil)
local ss_url = converter.render_template(converter.URL_TEMPLATES.ss, ss_node)
check("ss url contains method", ss_url:find("aes-256-gcm", 1, true) ~= nil)
check("ss url contains password", ss_url:find("ss-pass", 1, true) ~= nil)
-- ---------- 结果 ----------
print(string.format("\n%d passed, %d failed", passed, failed))
os.exit(failed == 0 and 0 or 1)
@@ -0,0 +1,81 @@
-- converter_test.lua — 转换引擎单元测试
-- 用法:lua5.1 tests/converter_test.lua
package.path = "./root/usr/share/?.lua;" .. package.path
local converter = require("substore.converter")
local passed, failed = 0, 0
local function check(name, cond)
if cond then
passed = passed + 1
print("PASS " .. name)
else
failed = failed + 1
print("FAIL " .. name)
end
end
-- ---------- 基础功能测试 ----------
check("module exists", converter ~= nil)
check("validate_nodes method exists", type(converter.validate_nodes) == "function")
check("convert method exists", type(converter.convert) == "function")
check("process_pipeline method exists", type(converter.process_pipeline) == "function")
check("apply_template method exists", type(converter.apply_template) == "function")
-- ---------- validate_nodes 测试 ----------
local valid_nodes = {
{ proto = "vmess", server = "1.1.1.1", port = 443, name = "Node1" },
{ proto = "vless", server = "2.2.2.2", port = 8443, name = "Node2" },
}
local ok, errs = converter.validate_nodes(valid_nodes)
check("validate_nodes valid", ok == true and (errs == nil or #errs == 0))
local invalid_nodes = {
{ proto = "vmess", server = "1.1.1.1", port = nil },
{ proto = nil, server = "2.2.2.2", port = 443 },
}
local ok2, errs2 = converter.validate_nodes(invalid_nodes)
check("validate_nodes invalid", ok2 == false and errs2 ~= nil and #errs2 > 0)
-- ---------- convert 测试 ----------
local nodes_for_output = {
{ proto = "vmess", name = "TestVMess", server = "1.1.1.1", port = 443, uuid = "uuid-1" },
{ proto = "trojan", name = "TestTrojan", server = "2.2.2.2", port = 443, password = "pass" },
}
local yaml_out = converter.convert(nodes_for_output, "clash", {})
check("convert clash returns string", type(yaml_out) == "string" and yaml_out:find("proxies:") ~= nil)
check("convert clash contains node name", yaml_out and yaml_out:find("TestVMess") ~= nil)
local json_out = converter.convert(nodes_for_output, "json", {})
check("convert json returns string", type(json_out) == "string" and json_out:find("TestVMess") ~= nil)
local b64_out = converter.convert(nodes_for_output, "base64", {})
check("convert base64 returns string", type(b64_out) == "string" and #b64_out > 0)
-- ---------- process_pipeline 测试 ----------
local pipeline_nodes = {
{ proto = "vmess", name = "HK VMESS", server = "1.1.1.1", port = 443 },
{ proto = "trojan", name = "US TROJAN", server = "2.2.2.2", port = 443 },
{ proto = "vmess", name = "JP VMESS", server = "3.3.3.3", port = 443 },
}
local pipeline = {
{ type = "filter", opts = { proto = "vmess" } },
}
local filtered = converter.process_pipeline(pipeline_nodes, pipeline)
check("process_pipeline filter count", filtered ~= nil and #filtered == 2)
check("process_pipeline filter proto", filtered and filtered[1].proto == "vmess" and filtered[2].proto == "vmess")
-- ---------- apply_template 测试 ----------
local template_nodes = {
{ proto = "vmess", name = "NodeA", server = "1.1.1.1", port = 443 },
}
local template = "{{name}}-{{proto}}"
local templated = converter.apply_template(template_nodes, template)
check("apply_template returns table", type(templated) == "table")
check("apply_template preserves count", templated ~= nil and #templated == 1)
-- ---------- 结果 ----------
print(string.format("\n%d passed, %d failed", passed, failed))
os.exit(failed == 0 and 0 or 1)
+106
View File
@@ -0,0 +1,106 @@
-- core_combo_test.lua — 组合订阅(选择性合并 + 组合订阅链接)单元测试
-- 用法:lua5.1 tests/core_combo_test.lua
package.path = "./root/usr/share/?.lua;" .. package.path
local core = require("substore.core")
local passed, failed = 0, 0
local function check(name, cond)
if cond then
passed = passed + 1
print("PASS " .. name)
else
failed = failed + 1
print("FAIL " .. name)
end
end
-- 重定向数据目录到临时目录,避免污染系统
local tmp = os.tmpname() .. "_substore"
os.execute("mkdir -p " .. string.format("%q", tmp .. "/nodes"))
core.DATA_DIR = tmp
core.LIST_FILE = tmp .. "/subscriptions.json"
core.NODES_DIR = tmp .. "/nodes"
check("add_combo exists", type(core.add_combo) == "function")
check("combo_refresh exists", type(core.combo_refresh) == "function")
check("refresh_combos exists", type(core.refresh_combos) == "function")
check("save_combo exists", type(core.save_combo) == "function")
-- 建两个源订阅
local a = core.add("订阅A", "http://example.com/a")
local b = core.add("订阅B", "http://example.com/b")
check("source A added", a ~= nil)
check("source B added", b ~= nil)
core.write_nodes(a, {
{ proto = "vmess", name = "A-香港-01", server = "1.1.1.1", port = 443, uuid = "u1" },
{ proto = "vmess", name = "A-美国-01", server = "2.2.2.2", port = 443, uuid = "u2" },
})
core.write_nodes(b, {
{ proto = "shadowsocks", name = "B-日本-01", server = "3.3.3.3", port = 8388, method = "aes-256-gcm", password = "p" },
})
-- 创建组合:合并 A+B
local c = core.add_combo("我的组合", { a, b })
check("add_combo returns id", c ~= nil)
local cmeta = core.get(c)
check("combo flag set", cmeta.combo == true)
check("combo has 2 sources", type(cmeta.sources) == "table" and #cmeta.sources == 2)
check("combo node_count = 3", cmeta.node_count == 3)
local cnodes = core.read_nodes(c)
check("combo read_nodes = 3", #cnodes == 3)
-- 组合 token → 生成订阅链接,含两源节点
local token = core.ensure_token(c)
local yaml, ct, filename = core.generate_link(token, "ClashMeta")
check("combo generate_link ok", type(yaml) == "string" and yaml ~= "")
check("combo clashmeta contains A node", yaml and yaml:find("A-香港-01", 1, true) ~= nil)
check("combo clashmeta contains B node", yaml and yaml:find("B-日本-01", 1, true) ~= nil)
-- 关键词规则:只保留香港
local c2 = core.add_combo("香港组合", { a }, { rules_enable = "1", keyword_include = "香港" })
check("combo with keyword include node_count = 1", core.get(c2).node_count == 1)
-- 去重:A 与 B 含同 server:port 节点
core.write_nodes(b, {
{ proto = "vmess", name = "A-香港-01", server = "1.1.1.1", port = 443, uuid = "u1" },
})
core.refresh_combos(a)
core.refresh_combos(b)
local c3 = core.add_combo("去重组合", { a, b }, { rules_enable = "1", dedup = "1" })
check("combo with dedup removes dup", core.get(c3).node_count == 2)
-- 陈旧性:源 A 追加节点后,refresh_combos 使组合跟随更新
core.write_nodes(a, {
{ proto = "vmess", name = "A-香港-01", server = "1.1.1.1", port = 443, uuid = "u1" },
{ proto = "vmess", name = "A-美国-01", server = "2.2.2.2", port = 443, uuid = "u2" },
{ proto = "vmess", name = "A-新增-01", server = "4.4.4.4", port = 443, uuid = "u3" },
})
core.refresh_combos(a)
check("combo follows source refresh", core.get(c).node_count == 4)
-- 编辑组合:改名称、改来源为仅 B
local cnt = core.save_combo(c, "改名组合", { b }, {})
check("save_combo returns count", cnt == 1)
check("combo renamed", core.get(c).name == "改名组合")
check("combo sources updated", #core.get(c).sources == 1 and core.get(c).sources[1] == b)
-- 空 sources 报错
local e, eerr = core.add_combo("空组合", {})
check("empty sources returns nil", e == nil)
check("empty sources err", eerr ~= nil)
-- 非法源 id 被忽略 → 视为空,报错
local e2 = core.add_combo("坏源", { "not-a-valid-id!!!" })
check("invalid source returns nil", e2 == nil)
-- 清理
os.execute("rm -rf " .. string.format("%q", tmp))
print(string.format("\n%d passed, %d failed", passed, failed))
os.exit(failed == 0 and 0 or 1)
@@ -0,0 +1,63 @@
-- core_cron_test.lua — 按订阅定时更新(cron)单元测试
-- 用法:lua5.1 tests/core_cron_test.lua
package.path = "./root/usr/share/?.lua;" .. package.path
local core = require("substore.core")
local util = require("substore.util")
local passed, failed = 0, 0
local function check(name, cond)
if cond then passed = passed + 1; print("PASS " .. name)
else failed = failed + 1; print("FAIL " .. name) end
end
-- 重定向数据目录与 cron 文件到临时目录,避免污染系统
local tmp = os.tmpname() .. "_substore_cron"
os.execute("mkdir -p " .. string.format("%q", tmp .. "/nodes"))
core.DATA_DIR = tmp
core.LIST_FILE = tmp .. "/subscriptions.json"
core.NODES_DIR = tmp .. "/nodes"
core.CRON_FILE = tmp .. "/cron.d.substore"
-- ---------- cron_time_valid ----------
check("valid cron", core.cron_time_valid("0 3 * * *"))
check("valid all wildcard", core.cron_time_valid("* * * * *"))
check("valid single digit", core.cron_time_valid("30 4 1 12 6"))
check("invalid too many fields", not core.cron_time_valid("0 3 * * * extra"))
check("invalid too few fields", not core.cron_time_valid("0 3 * *"))
check("invalid empty", not core.cron_time_valid(""))
check("invalid injection semicolon", not core.cron_time_valid("0 3 * * *;rm -rf /"))
check("invalid injection pipe", not core.cron_time_valid("0 3 * * * | reboot"))
check("invalid non-digit", not core.cron_time_valid("0 3 * * abc"))
-- ---------- add 带 cron ----------
local id1 = core.add("带定时", "http://example.com/a", { cron_enable = "1", cron_time = "0 4 * * *" })
local m1 = core.get(id1)
check("add with cron enabled", m1.cron_enable == true and m1.cron_time == "0 4 * * *")
local id2 = core.add("无定时", "http://example.com/b")
local m2 = core.get(id2)
check("add default cron disabled", m2.cron_enable ~= true and m2.cron_time == "")
local id3 = core.add("无效定时", "http://example.com/c", { cron_enable = "1", cron_time = "0 0 * * ;evil" })
local m3 = core.get(id3)
check("add invalid cron sanitized", m3.cron_enable ~= true and m3.cron_time == "")
-- ---------- write_cron ----------
core.write_cron()
local raw = util.read_file(core.CRON_FILE) or ""
check("cron file has enabled sub", raw:find(id1, 1, true) ~= nil)
check("cron file excludes disabled", raw:find(id2, 1, true) == nil)
check("cron file has header comment", raw:find("# luci-app-substore cron", 1, true) ~= nil)
-- 全部关闭后:cron 文件应被移除
core.save_meta(id1, { cron_enable = "0" })
core.write_cron()
check("cron file removed when none enabled", util.read_file(core.CRON_FILE) == nil)
-- 清理
os.execute("rm -rf " .. string.format("%q", tmp))
print(string.format("\n%d passed, %d failed", passed, failed))
os.exit(failed == 0 and 0 or 1)
@@ -0,0 +1,83 @@
-- core_link_test.lua — 订阅链接生成(core.generate_link)单元测试
-- 用法:lua5.1 tests/core_link_test.lua
package.path = "./root/usr/share/?.lua;" .. package.path
local core = require("substore.core")
local util = require("substore.util")
local passed, failed = 0, 0
local function check(name, cond)
if cond then
passed = passed + 1
print("PASS " .. name)
else
failed = failed + 1
print("FAIL " .. name)
end
end
-- 重定向数据目录到临时目录,避免污染系统
local tmp = os.tmpname() .. "_substore"
os.execute("mkdir -p " .. string.format("%q", tmp .. "/nodes"))
core.DATA_DIR = tmp
core.LIST_FILE = tmp .. "/subscriptions.json"
core.NODES_DIR = tmp .. "/nodes"
check("module exists", core ~= nil)
check("generate_link exists", type(core.generate_link) == "function")
check("ensure_token exists", type(core.ensure_token) == "function")
-- 创建订阅并写入节点
local id, aerr = core.add("测试订阅", "http://example.com/sub")
check("add subscription", id ~= nil, aerr or "")
local token = core.ensure_token(id)
check("token generated", type(token) == "string" and #token == 16)
check("token idempotent", core.ensure_token(id) == token)
local nodes = {
{ proto = "vmess", name = "NodeA", server = "1.1.1.1", port = 443, uuid = "u1", net = "tcp" },
{ proto = "shadowsocks", name = "NodeB", server = "2.2.2.2", port = 8388, method = "aes-256-gcm", password = "p1" },
}
check("write nodes", core.write_nodes(id, nodes) == true)
-- 各格式生成
local ok = true
for _, target in ipairs({ "ClashMeta", "singbox", "v2ray", "Surge", "QX", "Shadowrocket", "V2RayURI", "Plain", "Stash", "Loon", "Egern", "Surfboard", "SurgeMac" }) do
local content, ct, filename, err = core.generate_link(token, target)
if type(content) == "string" and content ~= "" and ct and filename then
-- ok
else
ok = false
print(" fail:", target, tostring(err))
end
end
check("all 13 targets generate", ok)
local yaml, ct, filename = core.generate_link(token, "ClashMeta")
check("clashmeta has proxies", yaml and yaml:find("proxies:") ~= nil)
check("clashmeta content-type", ct == "text/plain; charset=utf-8")
check("clashmeta filename .yaml", filename and filename:match("%.yaml$") ~= nil)
local sb = core.generate_link(token, "singbox")
check("singbox is json", sb and sb:sub(1, 1) == "{")
-- 错误 token
local bad, _, _, err = core.generate_link("deadbeef", "ClashMeta")
check("bad token returns nil", bad == nil)
check("bad token has err", err ~= nil)
-- 无节点订阅
local id2 = core.add("空订阅", "http://example.com/empty")
local token2 = core.ensure_token(id2)
local empty, _, _, err2 = core.generate_link(token2, "ClashMeta")
check("empty nodes returns nil", empty == nil)
check("empty nodes err", err2 ~= nil)
-- 清理
os.execute("rm -rf " .. string.format("%q", tmp))
print(string.format("\n%d passed, %d failed", passed, failed))
os.exit(failed == 0 and 0 or 1)
@@ -0,0 +1,57 @@
-- core.merge_form_node 测试:表单字段整体替换、非表单字段保留
package.path = "root/usr/share/?.lua;root/usr/share/?/init.lua;" .. package.path
-- core.lua 的 merge_form_node 不依赖 io/网络,可直接测试
local core = require("substore.core")
local passed, failed = 0, 0
local function check(name, cond)
if cond then passed = passed + 1; print("PASS " .. name)
else failed = failed + 1; print("FAIL " .. name) end
end
local orig = {
proto = "vmess", name = "old", group = "g1",
server = "a.com", port = 443, uuid = "u1", sni = "a.com",
raw = "vmess://xxx", tags = { "fast" }, remark_src = "机场A",
}
-- 表单提交的新值:改名、换分组、清空 sni、改端口
local formnode = {
proto = "vmess", name = "new", group = "g2",
server = "a.com", port = 8443, uuid = "u1",
}
local m = core.merge_form_node(orig, formnode)
check("name replaced", m.name == "new")
check("group replaced", m.group == "g2")
check("port replaced", m.port == 8443)
check("server kept", m.server == "a.com")
check("sni cleared (form-managed, absent in form)", m.sni == nil)
check("raw preserved", m.raw == "vmess://xxx")
check("tags preserved", m.tags and m.tags[1] == "fast")
check("custom field preserved", m.remark_src == "机场A")
check("proto kept", m.proto == "vmess")
check("no type key", m.type == nil)
-- 分组清空:formnode 无 group 键 → 清除
local m2 = core.merge_form_node(orig, { proto = "vmess", name = "n", server = "a.com", port = 1 })
check("group cleared when absent", m2.group == nil)
-- 协议切换:旧协议字段被清除
local m3 = core.merge_form_node(orig, {
proto = "trojan", name = "t", server = "a.com", port = 443, password = "pw",
})
check("proto switched", m3.proto == "trojan")
check("uuid cleared after proto switch", m3.uuid == nil)
check("password set", m3.password == "pw")
-- 空表/空原节点容错
local m4 = core.merge_form_node(nil, { name = "x" })
check("nil orig ok", m4.name == "x")
local m5 = core.merge_form_node(orig, nil)
check("nil form keeps non-form fields", m5.raw == "vmess://xxx")
check("nil form clears form fields", m5.name == nil)
print(string.format("%d passed, %d failed", passed, failed))
os.exit(failed == 0 and 0 or 1)
@@ -0,0 +1,82 @@
-- core_rules_test.lua — 订阅级规则单元测试
-- 用法:lua5.1 tests/core_rules_test.lua
package.path = "./root/usr/share/?.lua;" .. package.path
local core = require("substore.core")
local util = require("substore.util")
local passed, failed = 0, 0
local function check(name, cond)
if cond then passed = passed + 1; print("PASS " .. name)
else failed = failed + 1; print("FAIL " .. name) end
end
-- 重定向数据目录与 cron 文件到临时目录,避免污染系统
local tmp = os.tmpname() .. "_substore_rules"
os.execute("mkdir -p " .. string.format("%q", tmp .. "/nodes"))
core.DATA_DIR = tmp
core.LIST_FILE = tmp .. "/subscriptions.json"
core.NODES_DIR = tmp .. "/nodes"
core.CRON_FILE = tmp .. "/cron.d.substore"
local nodes = {
{ name = "HK-vmess", proto = "vmess", server = "1.1.1.1", port = 443 },
{ name = "US-vless", proto = "vless", server = "2.2.2.2", port = 443 },
{ name = "HK-trojan", proto = "trojan", server = "3.3.3.3", port = 443 },
{ name = "HK-vmess-dup", proto = "vmess", server = "1.1.1.1", port = 443 },
}
-- ---------- add 存储规则字段 ----------
local id = core.add("规则订阅", "http://example.com/r", {
rules_enable = "1", proto_filter = "vmess,vless",
keyword_include = "HK", keyword_exclude = "US",
dedup = "1", rename_map = "HK-vmess=香港节点",
})
local m = core.get(id)
check("add stores rules_enable", m.rules_enable == true)
check("add stores proto_filter", m.proto_filter == "vmess,vless")
check("add stores keyword_include", m.keyword_include == "HK")
check("add stores dedup", m.dedup == "1")
-- ---------- apply_rules 禁用时不变 ----------
local r0 = core.apply_rules(nodes, { rules_enable = "0" })
check("disabled returns unchanged", #r0 == #nodes)
-- ---------- apply_rules 协议过滤 ----------
local r1 = core.apply_rules(nodes, { rules_enable = "1", proto_filter = "vmess" })
check("proto_filter keeps only vmess", #r1 == 2)
-- ---------- 关键词包含 / 排除 ----------
local r2 = core.apply_rules(nodes, { rules_enable = "1", keyword_include = "HK" })
check("keyword_include HK", #r2 == 3)
local r3 = core.apply_rules(nodes, { rules_enable = "1", keyword_exclude = "US" })
check("keyword_exclude US", #r3 == 3)
-- ---------- 多关键词(逗号分隔)----------
local rm1 = core.apply_rules(nodes, { rules_enable = "1", keyword_include = "HK,trojan" })
check("multi keyword_include HK,trojan", #rm1 == 3)
local rm2 = core.apply_rules(nodes, { rules_enable = "1", keyword_exclude = "US,vless" })
check("multi keyword_exclude US,vless", #rm2 == 3)
local rm3 = core.apply_rules(nodes, { rules_enable = "1", keyword_include = "HK,trojan" })
check("multi keyword Chinese comma", #rm3 == 3)
-- ---------- 去重 ----------
local r4 = core.apply_rules(nodes, { rules_enable = "1", dedup = "1" })
check("dedup removes duplicate", #r4 == 3)
-- ---------- 重命名 ----------
local r5 = core.apply_rules(nodes, { rules_enable = "1", rename_map = "HK-vmess=香港节点" })
local renamed = false
for _, n in ipairs(r5) do if n.name == "香港节点" then renamed = true end end
check("rename_map applies", renamed)
-- ---------- 组合规则 ----------
local r6 = core.apply_rules(nodes, { rules_enable = "1", proto_filter = "vmess", dedup = "1", keyword_include = "HK" })
check("combined filter+dedup+keyword", #r6 == 1)
-- 清理
os.execute("rm -rf " .. string.format("%q", tmp))
print(string.format("\n%d passed, %d failed", passed, failed))
os.exit(failed == 0 and 0 or 1)
@@ -0,0 +1,45 @@
-- core_userinfo_test.lua — subscription-userinfo 解析与人性化格式单元测试
-- 用法:lua5.1 tests/core_userinfo_test.lua
package.path = "./root/usr/share/?.lua;" .. package.path
local core = require("substore.core")
local util = require("substore.util")
local passed, failed = 0, 0
local function check(name, cond)
if cond then passed = passed + 1; print("PASS " .. name)
else failed = failed + 1; print("FAIL " .. name) end
end
-- ---------- parse_userinfo ----------
local u = core.parse_userinfo("upload=1024; download=2048; total=107374182400; expire=1757951999")
check("userinfo upload", u and u.upload == 1024)
check("userinfo download", u and u.download == 2048)
check("userinfo total", u and u.total == 107374182400)
check("userinfo expire", u and u.expire == 1757951999)
check("userinfo trim spaces", core.parse_userinfo("upload = 1 ; download=2") ~= nil)
check("userinfo empty nil", core.parse_userinfo("") == nil)
check("userinfo nonstring nil", core.parse_userinfo(nil) == nil)
check("userinfo no fields nil", core.parse_userinfo("foo=bar") == nil)
local u2 = core.parse_userinfo("total=100")
check("userinfo partial", u2 ~= nil and u2.total == 100 and u2.upload == nil)
-- ---------- human_bytes ----------
check("human_bytes zero", util.human_bytes(0) == "0B")
check("human_bytes 20G", util.human_bytes(20 * 1024 * 1024 * 1024) == "20G")
check("human_bytes 512M", util.human_bytes(512 * 1024 * 1024) == "512M")
check("human_bytes 1.5G", util.human_bytes(1.5 * 1024 * 1024 * 1024) == "1.5G")
check("human_bytes bytes", util.human_bytes(512) == "512B")
check("human_bytes negative", util.human_bytes(-5) == "0B")
-- ---------- human_duration ----------
check("human_duration 10天", util.human_duration(10 * 86400) == "10天")
check("human_duration 8小时", util.human_duration(8 * 3600) == "8小时")
check("human_duration 30分钟", util.human_duration(30 * 60) == "30分钟")
check("human_duration expired", util.human_duration(-1) == "已过期")
check("human_duration under minute", util.human_duration(30) == "不足1分钟")
print(string.format("\n%d passed, %d failed", passed, failed))
os.exit(failed == 0 and 0 or 1)
@@ -0,0 +1,35 @@
-- http_proxy_test.lua — 代理地址校验单元测试
-- 用法:lua5.1 tests/http_proxy_test.lua
package.path = "./root/usr/share/?.lua;" .. package.path
local http = require("substore.http")
local passed, failed = 0, 0
local function check(name, cond)
if cond then passed = passed + 1; print("PASS " .. name)
else failed = failed + 1; print("FAIL " .. name) end
end
-- ---------- parse_proxy 合法输入 ----------
check("empty proxy", http.parse_proxy("") == "")
check("http proxy", http.parse_proxy("http://192.168.5.1:8080") == "http://192.168.5.1:8080")
check("socks5 proxy", http.parse_proxy("socks5://192.168.5.1:1080") == "socks5://192.168.5.1:1080")
check("socks5h proxy", http.parse_proxy("socks5h://10.0.0.1:1080") == "socks5h://10.0.0.1:1080")
check("https proxy", http.parse_proxy("https://proxy.example.com:8443") == "https://proxy.example.com:8443")
check("auth proxy", http.parse_proxy("http://user:pass@192.168.5.1:8080") == "http://user:pass@192.168.5.1:8080")
check("trim spaces", http.parse_proxy(" socks5://192.168.5.1:1080 ") == "socks5://192.168.5.1:1080")
check("strip path", http.parse_proxy("http://192.168.5.1:8080/") == "http://192.168.5.1:8080")
-- ---------- parse_proxy 非法输入 ----------
check("bad scheme", http.parse_proxy("ftp://x:80") == nil)
check("no scheme", http.parse_proxy("192.168.5.1:1080") == nil)
check("bad port", http.parse_proxy("http://x:99999") == nil)
check("port zero", http.parse_proxy("http://x:0") == nil)
check("injection host", http.parse_proxy("http://192.168.5.1:1080; rm -rf /") == nil)
check("injection userinfo", http.parse_proxy("http://user;ls@192.168.5.1:1080") == nil)
local _, e = http.parse_proxy("ftp://x:80")
check("err is string", type(e) == "string")
print(string.format("\n%d passed, %d failed", passed, failed))
os.exit(failed == 0 and 0 or 1)
@@ -0,0 +1,177 @@
-- node_converter_test.lua — 协议转换单元测试
-- 用法:lua5.1 tests/node_converter_test.lua
package.path = "./root/usr/share/?.lua;" .. package.path
local converter = require("substore.node_converter")
local passed, failed = 0, 0
local function check(name, cond)
if cond then
passed = passed + 1
print("PASS " .. name)
else
failed = failed + 1
print("FAIL " .. name)
end
end
local function eq(a, b)
if type(a) ~= type(b) then return false end
if type(a) == "table" then
for k, v in pairs(a) do if not eq(v, b[k]) then return false end end
for k in pairs(b) do if a[k] == nil then return false end end
return true
end
return a == b
end
-- ---------- 基本功能测试 ----------
check("module exists", converter ~= nil)
check("convert method exists", type(converter.convert) == "function")
check("get_field_mapping method exists", type(converter.get_field_mapping) == "function")
-- ---------- vmess 转换测试 ----------
local vmess_node = {
proto = "vmess",
server = "1.1.1.1",
port = 443,
uuid = "test-uuid-vmess",
alterId = 0,
cipher = "auto",
network = "ws",
}
local vless_converted = converter.convert(vmess_node, "vless")
check("vmess to vless proto", vless_converted and vless_converted.proto == "vless")
check("vmess to vless uuid", vless_converted and vless_converted.uuid == "test-uuid-vmess")
check("vmess to vless server", vless_converted and vless_converted.server == "1.1.1.1")
check("vmess to vless port", vless_converted and vless_converted.port == 443)
local trojan_converted = converter.convert(vmess_node, "trojan")
check("vmess to trojan proto", trojan_converted and trojan_converted.proto == "trojan")
check("vmess to trojan password", trojan_converted and trojan_converted.password == "test-uuid-vmess")
check("vmess to trojan server", trojan_converted and trojan_converted.server == "1.1.1.1")
local ss_converted = converter.convert(vmess_node, "shadowsocks")
check("vmess to ss proto", ss_converted and ss_converted.proto == "shadowsocks")
check("vmess to ss password", ss_converted and ss_converted.password == "test-uuid-vmess")
-- ---------- vless 转换测试 ----------
local vless_node = {
proto = "vless",
server = "2.2.2.2",
port = 8443,
uuid = "test-uuid-vless",
security = "tls",
}
local vmess_from_vless = converter.convert(vless_node, "vmess")
check("vless to vmess proto", vmess_from_vless and vmess_from_vless.proto == "vmess")
check("vless to vmess uuid", vmess_from_vless and vmess_from_vless.uuid == "test-uuid-vless")
local trojan_from_vless = converter.convert(vless_node, "trojan")
check("vless to trojan proto", trojan_from_vless and trojan_from_vless.proto == "trojan")
check("vless to trojan password", trojan_from_vless and trojan_from_vless.password == "test-uuid-vless")
-- ---------- trojan 转换测试 ----------
local trojan_node = {
proto = "trojan",
server = "3.3.3.3",
port = 443,
password = "trojan-pass",
sni = "example.com",
}
local vmess_from_trojan = converter.convert(trojan_node, "vmess")
check("trojan to vmess proto", vmess_from_trojan and vmess_from_trojan.proto == "vmess")
check("trojan to vmess uuid", vmess_from_trojan and vmess_from_trojan.uuid == "trojan-pass")
local vless_from_trojan = converter.convert(trojan_node, "vless")
check("trojan to vless proto", vless_from_trojan and vless_from_trojan.proto == "vless")
check("trojan to vless uuid", vless_from_trojan and vless_from_trojan.uuid == "trojan-pass")
-- ---------- shadowsocks 转换测试 ----------
local ss_node = {
proto = "shadowsocks",
server = "4.4.4.4",
port = 8388,
method = "aes-256-gcm",
password = "ss-pass",
}
local vmess_from_ss = converter.convert(ss_node, "vmess")
check("ss to vmess proto", vmess_from_ss and vmess_from_ss.proto == "vmess")
check("ss to vmess uuid", vmess_from_ss and vmess_from_ss.uuid == "ss-pass")
local vless_from_ss = converter.convert(ss_node, "vless")
check("ss to vless proto", vless_from_ss and vless_from_ss.proto == "vless")
check("ss to vless uuid", vless_from_ss and vless_from_ss.uuid == "ss-pass")
local trojan_from_ss = converter.convert(ss_node, "trojan")
check("ss to trojan proto", trojan_from_ss and trojan_from_ss.proto == "trojan")
check("ss to trojan password", trojan_from_ss and trojan_from_ss.password == "ss-pass")
-- ---------- 字段映射测试 ----------
local mapping = converter.get_field_mapping("vmess", "vless")
check("vmess to vless mapping exists", mapping ~= nil)
check("vmess to vless has uuid", mapping and mapping.uuid == "uuid")
check("vmess to vless has server", mapping and mapping.server == "server")
local mapping2 = converter.get_field_mapping("vless", "trojan")
check("vless to trojan mapping exists", mapping2 ~= nil)
check("vless to trojan uuid to password", mapping2 and mapping2.uuid == "password")
-- ---------- 不支持的转换 ----------
local result = converter.convert(vmess_node, "unsupported_proto")
check("unsupported target returns nil", result == nil)
-- ---------- 字段兼容性测试 ----------
local vmess_with_net = {
proto = "vmess",
server = "5.5.5.5",
port = 443,
uuid = "uuid-1",
network = "tcp",
security = "tls",
}
local vless_with_net = converter.convert(vmess_with_net, "vless")
check("network preserved", vless_with_net and vless_with_net.network == "tcp")
check("security preserved", vless_with_net and vless_with_net.security == "tls")
-- ---------- hysteria2 测试 ----------
local hysteria_node = {
proto = "hysteria2",
server = "6.6.6.6",
port = 443,
password = "hysteria-pass",
}
local singbox_from_hysteria = converter.convert(hysteria_node, "sing-box")
check("hysteria2 to sing-box proto", singbox_from_hysteria and singbox_from_hysteria.proto == "sing-box")
-- ---------- tuic 测试 ----------
local tuic_node = {
proto = "tuic",
server = "7.7.7.7",
port = 443,
uuid = "tuic-uuid",
password = "tuic-pass",
}
local singbox_from_tuic = converter.convert(tuic_node, "sing-box")
check("tuic to sing-box proto", singbox_from_tuic and singbox_from_tuic.proto == "sing-box")
-- ---------- wireguard 测试 ----------
local wg_node = {
proto = "wireguard",
server = "8.8.8.8",
port = 51820,
private_key = "wg-private",
public_key = "wg-public",
}
local singbox_from_wg = converter.convert(wg_node, "sing-box")
check("wireguard to sing-box proto", singbox_from_wg and singbox_from_wg.proto == "sing-box")
-- ---------- 结果 ----------
print(string.format("\n%d passed, %d failed", passed, failed))
os.exit(failed == 0 and 0 or 1)
@@ -0,0 +1,88 @@
-- node_extended_test.lua — 扩展字段单元测试
package.path = "./root/usr/share/?.lua;" .. package.path
local node = require("substore.node")
local passed, failed = 0, 0
local function check(name, cond)
if cond then
passed = passed + 1
print("PASS " .. name)
else
failed = failed + 1
print("FAIL " .. name)
end
end
-- ---------- normalize ----------
local n1 = { proto = "vmess", server = "1.1.1.1", port = 443, group = "HK", tags = {"fast","vip"}, remarks = "test", template = "vmess://{uuid}@{server}:{port}", url = "https://example.com" }
node.normalize(n1)
check("normalize preserves group", n1.group == "HK")
check("normalize preserves tags", type(n1.tags) == "table" and n1.tags[1] == "fast" and n1.tags[2] == "vip")
check("normalize preserves remarks", n1.remarks == "test")
check("normalize preserves template", n1.template == "vmess://{uuid}@{server}:{port}")
check("normalize preserves url", n1.url == "https://example.com")
-- tags as string should be converted to table? Optional
local n2 = { proto = "vless", server = "2.2.2.2", port = 443, tags = "fast,vip" }
node.normalize(n2)
-- If we choose to normalize tags string to table
-- For now just check preservation
check("normalize keeps tags string", n2.tags == "fast,vip" or (type(n2.tags)=="table" and n2.tags[1]=="fast"))
-- ---------- filter ----------
local nodes = {
{ proto = "vmess", server = "1.1.1.1", port = 443, group = "HK", tags = {"fast"} },
{ proto = "vless", server = "2.2.2.2", port = 443, group = "JP", tags = {"slow"} },
{ proto = "vmess", server = "3.3.3.3", port = 443, group = "HK", tags = {"fast","vip"} },
}
local f1 = node.filter(nodes, { group = "HK" })
check("filter by group count", #f1 == 2)
check("filter by group values", f1[1].group == "HK" and f1[2].group == "HK")
local f2 = node.filter(nodes, { tags = "vip" })
check("filter by tags count", #f2 == 1)
check("filter by tags value", f2[1].server == "3.3.3.3")
-- ---------- apply_rules ----------
local nodes2 = {
{ proto = "vmess", name = "A", group = "HK" },
{ proto = "vless", name = "B", group = "JP" },
{ proto = "vmess", name = "C", group = "HK" },
}
local rules = { group_filter = "HK" }
local r1 = node.apply_rules(nodes2, rules)
check("apply_rules group_filter count", #r1 == 2)
check("apply_rules group_filter values", r1[1].group == "HK" and r1[2].group == "HK")
local nodes3 = {
{ proto = "vmess", name = "A", tags = {"fast"} },
{ proto = "vmess", name = "B", tags = {"slow"} },
}
local rules2 = { tags_include = "fast" }
local r2 = node.apply_rules(nodes3, rules2)
check("apply_rules tags_include count", #r2 == 1)
check("apply_rules tags_include value", r2[1].name == "A")
-- ---------- group_nodes ----------
local nodes4 = {
{ name = "A", group = "HK" },
{ name = "B", group = "JP" },
{ name = "C", group = "HK" },
}
local groups = node.group_nodes(nodes4)
check("group_nodes HK count", groups["HK"] and #groups["HK"] == 2)
check("group_nodes JP count", groups["JP"] and #groups["JP"] == 1)
-- ---------- add_tags ----------
local nodes5 = {
{ name = "A", tags = {"fast"} },
{ name = "B" },
}
node.add_tags(nodes5, {"vip"})
check("add_tags existing", nodes5[1].tags and #nodes5[1].tags == 2 and nodes5[1].tags[2] == "vip")
check("add_tags new", nodes5[2].tags and #nodes5[2].tags == 1 and nodes5[2].tags[1] == "vip")
print(string.format("\n%d passed, %d failed", passed, failed))
os.exit(failed == 0 and 0 or 1)
+132
View File
@@ -0,0 +1,132 @@
-- node_group_test.lua — 节点分组与标签测试
package.path = "./root/usr/share/?.lua;" .. package.path
local node = require("substore.node")
local passed, failed = 0, 0
local function check(name, cond)
if cond then
passed = passed + 1
print("PASS " .. name)
else
failed = failed + 1
print("FAIL " .. name)
end
end
local function eq(a, b)
if type(a) ~= type(b) then return false end
if type(a) == "table" then
for k, v in pairs(a) do if not eq(v, b[k]) then return false end end
for k in pairs(b) do if a[k] == nil then return false end end
return true
end
return a == b
end
-- 测试数据
local nodes = {
{ name = "HK VMESS 1", proto = "vmess", server = "1.1.1.1", port = 443 },
{ name = "HK VMESS 2", proto = "vmess", server = "1.1.1.2", port = 443 },
{ name = "US TROJAN 1", proto = "trojan", server = "2.2.2.2", port = 443 },
{ name = "JP SS 1", proto = "shadowsocks", server = "3.3.3.3", port = 8388 },
{ name = "HK TROJAN 2", proto = "trojan", server = "1.1.1.1", port = 443 },
}
-- ---------- group_by ----------
check("group_by proto exists", type(node.group_by) == "function")
if type(node.group_by) == "function" then
local groups = node.group_by(nodes, "proto")
check("group_by proto count", groups ~= nil and groups.vmess ~= nil and #groups.vmess == 2)
check("group_by proto trojan", groups ~= nil and groups.trojan ~= nil and #groups.trojan == 2)
check("group_by proto shadowsocks", groups ~= nil and groups.shadowsocks ~= nil and #groups.shadowsocks == 1)
local groups_server = node.group_by(nodes, "server")
check("group_by server 1.1.1.1", groups_server ~= nil and groups_server["1.1.1.1"] ~= nil and #groups_server["1.1.1.1"] == 2)
check("group_by server 2.2.2.2", groups_server ~= nil and groups_server["2.2.2.2"] ~= nil and #groups_server["2.2.2.2"] == 1)
end
-- ---------- add_group ----------
check("add_group exists", type(node.add_group) == "function")
if type(node.add_group) == "function" then
local nodes_copy = {}
for _, n in ipairs(nodes) do
nodes_copy[#nodes_copy + 1] = { name = n.name, proto = n.proto, server = n.server, port = n.port }
end
-- 按协议分组
local rules_proto = {
{ field = "proto", prefix = "proto_" }
}
local result = node.add_group(nodes_copy, rules_proto)
check("add_group proto field exists", result[1].group ~= nil)
check("add_group proto value", result[1].group == "proto_vmess")
-- 按服务器分组
local nodes_copy2 = {}
for _, n in ipairs(nodes) do
nodes_copy2[#nodes_copy2 + 1] = { name = n.name, proto = n.proto, server = n.server, port = n.port }
end
local rules_server = {
{ field = "server", prefix = "server_" }
}
local result2 = node.add_group(nodes_copy2, rules_server)
check("add_group server field exists", result2[1].group ~= nil)
check("add_group server value", result2[1].group == "server_1.1.1.1")
end
-- ---------- add_tags ----------
check("add_tags exists", type(node.add_tags) == "function")
if type(node.add_tags) == "function" then
local nodes_copy = {}
for _, n in ipairs(nodes) do
nodes_copy[#nodes_copy + 1] = { name = n.name, proto = n.proto, server = n.server, port = n.port }
end
-- 按关键词添加标签
local rules_keyword = {
{ type = "keyword", value = "HK", tag = "hongkong" }
}
local result = node.add_tags(nodes_copy, rules_keyword)
check("add_tags keyword exists", result[1].tags ~= nil and type(result[1].tags) == "table")
check("add_tags keyword value", result[1].tags ~= nil and result[1].tags.hongkong == true)
check("add_tags keyword not match", result[3].tags ~= nil and result[3].tags.hongkong == nil)
end
-- ---------- filter_by_group ----------
check("filter_by_group exists", type(node.filter_by_group) == "function")
if type(node.filter_by_group) == "function" then
local nodes_copy = {}
for _, n in ipairs(nodes) do
nodes_copy[#nodes_copy + 1] = { name = n.name, proto = n.proto, server = n.server, port = n.port, group = (n.proto == "vmess") and "proto_vmess" or "other" }
end
local filtered = node.filter_by_group(nodes_copy, "proto_vmess")
check("filter_by_group count", filtered ~= nil and #filtered == 2)
local filtered_none = node.filter_by_group(nodes_copy, "proto_trojan")
check("filter_by_group none", filtered_none ~= nil and #filtered_none == 0)
end
-- ---------- filter_by_tags ----------
check("filter_by_tags exists", type(node.filter_by_tags) == "function")
if type(node.filter_by_tags) == "function" then
local nodes_copy = {}
for _, n in ipairs(nodes) do
nodes_copy[#nodes_copy + 1] = { name = n.name, proto = n.proto, server = n.server, port = n.port, tags = {} }
end
nodes_copy[1].tags.hongkong = true
nodes_copy[2].tags.hongkong = true
nodes_copy[3].tags.usa = true
local filtered = node.filter_by_tags(nodes_copy, { "hongkong" })
check("filter_by_tags count", filtered ~= nil and #filtered == 2)
local filtered_none = node.filter_by_tags(nodes_copy, { "japan" })
check("filter_by_tags none", filtered_none ~= nil and #filtered_none == 0)
end
-- 结果
print(string.format("\n%d passed, %d failed", passed, failed))
os.exit(failed == 0 and 0 or 1)
@@ -0,0 +1,123 @@
-- node_rename_test.lua — 节点重命名规则引擎单元测试
-- 用法:lua5.1 tests/node_rename_test.lua
package.path = "./root/usr/share/?.lua;" .. package.path
local node = require("substore.node")
local passed, failed = 0, 0
local function check(name, cond)
if cond then
passed = passed + 1
print("PASS " .. name)
else
failed = failed + 1
print("FAIL " .. name)
end
end
-- 辅助:创建节点
local function mk(proto, server, port, name)
return { proto = proto, server = server, port = port, name = name or (server .. ":" .. port) }
end
-- ---------- parse_rename_rules ----------
check("parse empty rules", #node.parse_rename_rules("") == 0)
local rules1 = node.parse_rename_rules("^(.*):(\\d+)$ -> $1")
check("parse regex rule count", #rules1 == 1)
check("parse regex rule type", rules1[1] and rules1[1].type == "regex")
check("parse regex pattern", rules1[1] and rules1[1].pattern == "^(.*):(\\d+)$")
check("parse regex replacement", rules1[1] and rules1[1].replacement == "$1")
local rules2 = node.parse_rename_rules("vmess -> VMESS")
check("parse simple replace", #rules2 == 1 and rules2[1].type == "regex")
check("parse simple pattern", rules2[1].pattern == "vmess")
check("parse simple replacement", rules2[1].replacement == "VMESS")
local rules3 = node.parse_rename_rules("{server}_{port}_{proto}")
check("parse template rule count", #rules3 == 1)
check("parse template rule type", rules3[1] and rules3[1].type == "template")
check("parse template content", rules3[1] and rules3[1].template == "{server}_{port}_{proto}")
local rules4 = node.parse_rename_rules([[
^(.*):(\d+)$ -> $1
vmess -> VMESS
{server}_{port}_{proto}
]])
check("parse multiple rules", #rules4 == 3)
-- 精确匹配:旧名称=新名称
local rules5 = node.parse_rename_rules("香港=HK")
check("parse exact rule count", #rules5 == 1)
check("parse exact rule type", rules5[1] and rules5[1].type == "exact")
check("parse exact old", rules5[1] and rules5[1].old == "香港")
check("parse exact new", rules5[1] and rules5[1].new == "HK")
local rules6 = node.parse_rename_rules(" 香港 = HK ")
check("parse exact trim", rules6[1] and rules6[1].old == "香港" and rules6[1].new == "HK")
-- 注释行应被忽略
local rules7 = node.parse_rename_rules("# 注释\n香港=HK")
check("parse exact skips comment", #rules7 == 1 and rules7[1].type == "exact")
-- ---------- rename_with_rules ----------
-- 测试正则替换
local nodes1 = { mk("vmess", "1.2.3.4", 443, "1.2.3.4:443") }
node.rename_with_rules(nodes1, node.parse_rename_rules("^(.*):(\\d+)$ -> $1"))
check("regex rename strip port", nodes1[1].name == "1.2.3.4")
-- 测试简单替换
local nodes2 = { mk("vmess", "1.2.3.4", 443, "vmess node") }
node.rename_with_rules(nodes2, node.parse_rename_rules("vmess -> VMESS"))
check("simple replace vmess", nodes2[1].name == "VMESS node")
-- 测试模板变量
local nodes3 = { mk("vmess", "1.2.3.4", 443, "old") }
node.rename_with_rules(nodes3, node.parse_rename_rules("{server}_{port}_{proto}"))
check("template rename", nodes3[1].name == "1.2.3.4_443_vmess")
-- 测试链式规则
local nodes4 = { mk("vmess", "1.2.3.4", 443, "1.2.3.4:443") }
node.rename_with_rules(nodes4, node.parse_rename_rules([[
^(.*):(\d+)$ -> $1
vmess -> VMESS
]]))
-- 第一步去掉端口 -> 1.2.3.4, 第二步 vmess->VMESS 不匹配
check("chain rule 1", nodes4[1].name == "1.2.3.4")
-- 测试模板后接替换
local nodes5 = { mk("vmess", "1.2.3.4", 443, "old") }
node.rename_with_rules(nodes5, node.parse_rename_rules([[
{server}_{port}_{proto}
vmess -> VMESS
]]))
check("template then replace", nodes5[1].name == "1.2.3.4_443_VMESS")
-- 测试精确匹配重命名
local nodes6 = { mk("vmess", "1.2.3.4", 443, "香港") }
node.rename_with_rules(nodes6, node.parse_rename_rules("香港=HK"))
check("exact rename match", nodes6[1].name == "HK")
-- 精确匹配不命中时不改动
local nodes7 = { mk("vmess", "1.2.3.4", 443, "东京") }
node.rename_with_rules(nodes7, node.parse_rename_rules("香港=HK"))
check("exact rename no match", nodes7[1].name == "东京")
-- 精确匹配 + 模板链式
local nodes8 = { mk("vmess", "1.2.3.4", 443, "香港") }
node.rename_with_rules(nodes8, node.parse_rename_rules([[
香港=HK
{name}_{port}
]]))
check("exact then template", nodes8[1].name == "HK_443")
-- 保持向后兼容:M.rename
local n = mk("vmess", "1.1.1.1", 443, "old")
node.rename(n, "new")
check("backward compat rename", n.name == "new")
-- ---------- 结果 ----------
print(string.format("\n%d passed, %d failed", passed, failed))
os.exit(failed == 0 and 0 or 1)
@@ -0,0 +1,152 @@
-- output_clash_meta_test.lua — Clash.Meta / Mihomo 输出单元测试
-- 用法:lua5.1 tests/output_clash_meta_test.lua
package.path = "./root/usr/share/?.lua;" .. package.path
local output_clash_meta = require("substore.output_clash_meta")
local passed, failed = 0, 0
local function check(name, cond)
if cond then
passed = passed + 1
print("PASS " .. name)
else
failed = failed + 1
print("FAIL " .. name)
end
end
-- ---------- 基础功能测试 ----------
check("module exists", output_clash_meta ~= nil)
check("generate method exists", type(output_clash_meta.generate) == "function")
-- ---------- 测试节点 ----------
local test_nodes = {
{
proto = "vmess",
name = "TestVMess",
server = "1.1.1.1",
port = 443,
uuid = "abc-123",
net = "ws",
security = "tls",
sni = "example.com",
path = "/ws",
host = "example.com",
},
{
proto = "vless",
name = "TestVLESS",
server = "2.2.2.2",
port = 443,
uuid = "vless-uuid-123",
net = "tcp",
security = "tls",
sni = "example.org",
alpn = "h2,http/1.1",
fp = "chrome",
},
{
proto = "trojan",
name = "TestTrojan",
server = "3.3.3.3",
port = 443,
password = "trojan-pass",
sni = "example.com",
["skip-cert-verify"] = true,
udp = true,
},
{
proto = "shadowsocks",
name = "TestSS",
server = "4.4.4.4",
port = 8388,
method = "aes-256-gcm",
password = "ss-pass",
},
{
proto = "hysteria2",
name = "TestHysteria2",
server = "5.5.5.5",
port = 443,
password = "hy2-pass",
sni = "hy2.example.com",
alpn = "h3",
},
{
proto = "tuic",
name = "TestTUIC",
server = "6.6.6.6",
port = 443,
uuid = "tuic-uuid",
password = "tuic-pass",
["udp-relay-mode"] = "native",
},
{
proto = "wireguard",
name = "TestWireGuard",
server = "7.7.7.7",
port = 51820,
["private-key"] = "wg-private",
["peer-public-key"] = "wg-peer",
["preshared-key"] = "wg-psk",
},
{
proto = "socks",
name = "TestSocks",
server = "8.8.8.8",
port = 1080,
username = "user",
password = "pass",
},
}
-- ---------- 生成测试 ----------
local options = {
name = "TestGroup",
latency_test = true,
health_check = true,
}
local yaml_output = output_clash_meta.generate(test_nodes, options)
check("generate returns string", type(yaml_output) == "string")
check("output contains proxies", yaml_output and yaml_output:find("proxies:") ~= nil)
check("output contains proxy-groups", yaml_output and yaml_output:find("proxy%-groups:") ~= nil)
check("output contains vmess node", yaml_output and yaml_output:find("TestVMess") ~= nil)
check("output contains vless node", yaml_output and yaml_output:find("TestVLESS") ~= nil)
check("output contains trojan node", yaml_output and yaml_output:find("TestTrojan") ~= nil)
check("output contains shadowsocks node", yaml_output and yaml_output:find("TestSS") ~= nil)
check("output contains hysteria2 node", yaml_output and yaml_output:find("TestHysteria2") ~= nil)
check("output contains tuic node", yaml_output and yaml_output:find("TestTUIC") ~= nil)
check("output contains wireguard node", yaml_output and yaml_output:find("TestWireGuard") ~= nil)
check("output contains socks node", yaml_output and yaml_output:find("TestSocks") ~= nil)
-- 检查协议类型映射
check("vmess type present", yaml_output and yaml_output:find("type: vmess") ~= nil)
check("vless type present", yaml_output and yaml_output:find("type: vless") ~= nil)
check("trojan type present", yaml_output and yaml_output:find("type: trojan") ~= nil)
check("ss type present", yaml_output and yaml_output:find("type: ss") ~= nil)
-- 检查特殊字段
check("skip-cert-verify present", yaml_output and yaml_output:find("skip%-cert%-verify") ~= nil)
check("udp present", yaml_output and yaml_output:find("udp:") ~= nil)
check("alpn present", yaml_output and yaml_output:find("alpn:") ~= nil)
check("fp present", yaml_output and yaml_output:find("fp:") ~= nil)
-- 检查 proxy-groups
check("SELECT group present", yaml_output and yaml_output:find("type: select") ~= nil)
check("URL-TEST group present", yaml_output and yaml_output:find("type: url%-test") ~= nil)
check("LOAD-BALANCE group present", yaml_output and yaml_output:find("type: load%-balance") ~= nil)
-- 检查选项
check("name option used", yaml_output and yaml_output:find("TestGroup") ~= nil)
-- ---------- 空节点测试 ----------
local empty_output = output_clash_meta.generate({}, {})
check("empty nodes handled", type(empty_output) == "string")
-- ---------- 结果 ----------
print(string.format("\n%d passed, %d failed", passed, failed))
os.exit(failed == 0 and 0 or 1)
@@ -0,0 +1,98 @@
-- output_formats_test.lua — 13 种目标格式统一分发单元测试
-- 用法:lua5.1 tests/output_formats_test.lua
package.path = "./root/usr/share/?.lua;" .. package.path
local output = require("substore.output")
local util = require("substore.util")
local passed, failed = 0, 0
local function check(name, cond)
if cond then
passed = passed + 1
print("PASS " .. name)
else
failed = failed + 1
print("FAIL " .. name)
end
end
local nodes = {
{ proto = "vmess", name = "VMess Node", server = "1.1.1.1", port = 443,
uuid = "abc-123", net = "ws", security = "tls", sni = "example.com",
path = "/ws", host = "example.com" },
{ proto = "vless", name = "VLESS Node", server = "2.2.2.2", port = 443,
uuid = "vless-uuid", security = "tls", sni = "example.org" },
{ proto = "trojan", name = "Trojan Node", server = "3.3.3.3", port = 443,
password = "trojan-pass", sni = "example.com" },
{ proto = "shadowsocks", name = "SS Node", server = "4.4.4.4", port = 8388,
method = "aes-256-gcm", password = "ss-pass" },
}
check("module exists", output ~= nil)
check("generate exists", type(output.generate) == "function")
-- 13 种目标格式均返回字符串
local formats = {
"clash", "clashmeta", "mihomo", "stash",
"surge", "surfboard", "surgemac", "loon", "egern",
"qx", "singbox", "v2ray", "v2rayuri", "shadowrocket", "plain",
}
for _, f in ipairs(formats) do
local s, err = output.generate(nodes, f)
check("generate " .. f .. " returns string", type(s) == "string" and (not err))
print(" ", err or "")
end
-- 格式头检查
local surge = output.generate(nodes, "surge")
check("surge has [Proxy]", surge:find("%[Proxy%]") ~= nil)
check("surge has vmess line", surge:find("VMess Node = vmess") ~= nil)
check("surge has ss line", surge:find("SS Node = shadowsocks") ~= nil)
local qx = output.generate(nodes, "qx")
check("qx has [server_local]", qx:find("%[server_local%]") ~= nil)
check("qx has vmess line", qx:find("vmess=1.1.1.1:443") ~= nil)
check("qx has policy", qx:find("%[policy%]") ~= nil)
local singbox = output.generate(nodes, "singbox")
check("singbox is json", singbox:find("{") == 1)
local sb = util.json_decode(singbox)
check("singbox has outbounds", sb and sb.outbounds ~= nil and #sb.outbounds == 4)
check("singbox type vmess", sb and sb.outbounds[1] and sb.outbounds[1].type == "vmess")
local v2ray = output.generate(nodes, "v2ray")
local vr = util.json_decode(v2ray)
check("v2ray has outbounds", vr and vr.outbounds ~= nil and #vr.outbounds == 4)
check("v2ray protocol vmess", vr and vr.outbounds[1] and vr.outbounds[1].protocol == "vmess")
local uri = output.generate(nodes, "v2rayuri")
check("uri list has vmess", uri:find("vmess://") ~= nil)
check("uri list has vless", uri:find("vless://") ~= nil)
check("uri list has trojan", uri:find("trojan://") ~= nil)
check("uri list has ss", uri:find("ss://") ~= nil)
local rocket = output.generate(nodes, "shadowrocket")
local dec = util.base64_decode(rocket)
check("shadowrocket is base64 of uri", dec:find("vmess://") ~= nil)
local plain = output.generate(nodes, "plain")
local pj = util.json_decode(plain)
check("plain is json array", type(pj) == "table" and pj[1] ~= nil and pj[1].proto == "vmess")
-- 别名映射
check("alias clashmeta", output.generate(nodes, "clashmeta") == output.generate(nodes, "clash"))
check("alias sing_box", output.generate(nodes, "sing_box") == output.generate(nodes, "singbox"))
-- 未知格式报错
local bad, err = output.generate(nodes, "nonsense")
check("unknown format returns nil", bad == nil)
check("unknown format has err", err ~= nil)
-- 空节点
check("empty nodes ok", type(output.generate({}, "surge")) == "string")
print(string.format("\n%d passed, %d failed", passed, failed))
os.exit(failed == 0 and 0 or 1)
@@ -0,0 +1,226 @@
-- parser_clash_yaml_test.lua — Clash YAML 解析单元测试
-- 用法:lua5.1 tests/parser_clash_yaml_test.lua
package.path = "./root/usr/share/?.lua;" .. package.path
local parser = require("substore.parser_clash_yaml")
local passed, failed = 0, 0
local function check(name, cond)
if cond then
passed = passed + 1
print("PASS " .. name)
else
failed = failed + 1
print("FAIL " .. name)
end
end
-- ---------- 基本 Clash YAML ----------
local clash_yaml_basic = [[
proxies:
- name: TestVMess
type: vmess
server: 1.1.1.1
port: 443
uuid: abc-123
alterId: 0
cipher: auto
network: ws
ws-opts:
path: /ws
headers:
Host: example.com
tls: true
servername: example.com
- name: TestVLESS
type: vless
server: 2.2.2.2
port: 443
uuid: vless-uuid-123
network: tcp
tls: true
sni: example.org
alpn:
- h2
- http/1.1
fp: chrome
- name: TestTrojan
type: trojan
server: 3.3.3.3
port: 443
password: trojan-pass
sni: example.com
skip-cert-verify: true
udp: true
- name: TestSS
type: ss
server: 4.4.4.4
port: 8388
cipher: aes-256-gcm
password: ss-pass
- name: TestHysteria2
type: hysteria2
server: 5.5.5.5
port: 443
password: hy2-pass
sni: hy2.example.com
alpn:
- h3
- name: TestTUIC
type: tuic
server: 6.6.6.6
port: 443
uuid: tuic-uuid
password: tuic-pass
udp-relay-mode: native
- name: TestWireGuard
type: wireguard
server: 7.7.7.7
port: 51820
uuid: wg-uuid
private-key: wg-private
peer-public-key: wg-peer
preshared-key: wg-psk
- name: TestSocks
type: socks5
server: 8.8.8.8
port: 1080
username: user
password: pass
]]
local nodes = parser.parse(clash_yaml_basic)
check("parse returns table", type(nodes) == "table")
check("node count", nodes and #nodes == 8)
if nodes and #nodes >= 1 then
local n1 = nodes[1]
check("vmess name", n1.name == "TestVMess")
check("vmess proto", n1.proto == "vmess")
check("vmess server", n1.server == "1.1.1.1")
check("vmess port", n1.port == 443)
check("vmess uuid", n1.uuid == "abc-123")
check("vmess net", n1.net == "ws")
check("vmess sni", n1.sni == "example.com")
end
if nodes and #nodes >= 2 then
local n2 = nodes[2]
check("vless name", n2.name == "TestVLESS")
check("vless proto", n2.proto == "vless")
check("vless server", n2.server == "2.2.2.2")
check("vless port", n2.port == 443)
check("vless uuid", n2.uuid == "vless-uuid-123")
check("vless sni", n2.sni == "example.org")
check("vless alpn", n2.alpn ~= nil)
check("vless fp", n2.fp == "chrome")
end
if nodes and #nodes >= 3 then
local n3 = nodes[3]
check("trojan name", n3.name == "TestTrojan")
check("trojan proto", n3.proto == "trojan")
check("trojan server", n3.server == "3.3.3.3")
check("trojan port", n3.port == 443)
check("trojan password", n3.password == "trojan-pass")
check("trojan sni", n3.sni == "example.com")
check("trojan skip-cert-verify", n3["skip-cert-verify"] == true or n3.skip_cert_verify == true)
check("trojan udp", n3.udp == true)
end
if nodes and #nodes >= 4 then
local n4 = nodes[4]
check("ss name", n4.name == "TestSS")
check("ss proto", n4.proto == "shadowsocks")
check("ss server", n4.server == "4.4.4.4")
check("ss port", n4.port == 8388)
check("ss method", n4.method == "aes-256-gcm")
check("ss password", n4.password == "ss-pass")
end
if nodes and #nodes >= 5 then
local n5 = nodes[5]
check("hysteria2 name", n5.name == "TestHysteria2")
check("hysteria2 proto", n5.proto == "hysteria2")
check("hysteria2 server", n5.server == "5.5.5.5")
check("hysteria2 port", n5.port == 443)
check("hysteria2 password", n5.password == "hy2-pass")
end
if nodes and #nodes >= 6 then
local n6 = nodes[6]
check("tuic name", n6.name == "TestTUIC")
check("tuic proto", n6.proto == "tuic")
check("tuic server", n6.server == "6.6.6.6")
end
if nodes and #nodes >= 7 then
local n7 = nodes[7]
check("wireguard name", n7.name == "TestWireGuard")
check("wireguard proto", n7.proto == "wireguard")
check("wireguard server", n7.server == "7.7.7.7")
end
if nodes and #nodes >= 8 then
local n8 = nodes[8]
check("socks name", n8.name == "TestSocks")
check("socks proto", n8.proto == "socks")
check("socks server", n8.server == "8.8.8.8")
end
-- ---------- proxy-groups ----------
local clash_yaml_groups = [[
proxies:
- name: Node1
type: vmess
server: 1.1.1.1
port: 443
uuid: u1
proxy-groups:
- name: Group1
type: select
proxies:
- Node1
- REJECT
]]
local nodes2 = parser.parse(clash_yaml_groups)
check("groups parse nodes", nodes2 and #nodes2 == 1)
check("groups parse name", nodes2 and nodes2[1].name == "Node1")
-- ---------- 流式 JSON 节点(机场 clash 配置常见写法)----------
local clash_yaml_flow = [[
proxies:
- {"name":"V1-FlowVMess","type":"vmess","server":"a.cdn.node.example.com","port":30849,"uuid":"u-1","alterId":0,"cipher":"auto","udp":true,"tags":"hk","network":"ws","ws-opts":{"path":"/009c.x.m3u8","headers":{"Host":"edge.example.com"}},"skip-cert-verify":false}
- {"name":"V1-FlowSSR","type":"ssr","server":"b.cdn.node.example.com","port":30800,"cipher":"chacha20-ietf","password":"pwd","protocol":"auth_aes128_sha1","protocol-param":"174188:MUQuUs","obfs":"tls1.2_ticket_auth","obfs-param":"cdn.com","udp":true,"tags":"jp"}
]]
local fnodes = parser.parse(clash_yaml_flow)
check("flow node count", fnodes and #fnodes == 2)
if fnodes and #fnodes >= 1 then
local f1 = fnodes[1]
check("flow vmess proto", f1.proto == "vmess")
check("flow vmess name", f1.name == "V1-FlowVMess")
check("flow vmess server", f1.server == "a.cdn.node.example.com")
check("flow vmess port", f1.port == 30849)
check("flow vmess uuid", f1.uuid == "u-1")
check("flow vmess net", f1.net == "ws")
check("flow vmess path", f1.path == "/009c.x.m3u8")
check("flow vmess host", f1.host == "edge.example.com")
end
if fnodes and #fnodes >= 2 then
local f2 = fnodes[2]
check("flow ssr proto", f2.proto == "ssr")
check("flow ssr server", f2.server == "b.cdn.node.example.com")
check("flow ssr cipher", f2.method == "chacha20-ietf")
check("flow ssr protocol", f2.protocol == "auth_aes128_sha1")
check("flow ssr obfs", f2.obfs == "tls1.2_ticket_auth")
end
-- ---------- 结果 ----------
print(string.format("\n%d passed, %d failed", passed, failed))
os.exit(failed == 0 and 0 or 1)
@@ -0,0 +1,109 @@
-- parser_input_test.lua — 多客户端配置导入(sing-box / V2Ray / Clash JSON / Surge / QX)单元测试
-- 用法:lua5.1 tests/parser_input_test.lua
package.path = "./root/usr/share/?.lua;" .. package.path
local parser = require("substore.parser")
local passed, failed = 0, 0
local function check(name, cond)
if cond then
passed = passed + 1
print("PASS " .. name)
else
failed = failed + 1
print("FAIL " .. name)
end
end
check("module exists", parser ~= nil)
check("parse exists", type(parser.parse) == "function")
-- ---------- sing-box JSON ----------
local singbox = [[
{
"outbounds": [
{ "type": "vmess", "tag": "sb-vmess", "server": "1.1.1.1", "server_port": 443, "uuid": "u1",
"tls": { "enabled": true, "server_name": "sni.example.com" } },
{ "type": "trojan", "tag": "sb-trojan", "server": "2.2.2.2", "server_port": 443, "password": "p1" },
{ "type": "shadowsocks", "tag": "sb-ss", "server": "3.3.3.3", "server_port": 8388, "method": "aes-256-gcm", "password": "p2" }
]
}
]]
check("detect singbox = json", parser.detect(singbox) == "json")
local r1 = parser.parse(singbox)
check("singbox parses", r1 and r1.nodes ~= nil)
check("singbox 3 nodes", r1 and #r1.nodes == 3)
check("singbox vmess uuid", r1 and r1.nodes[1] and r1.nodes[1].uuid == "u1")
check("singbox vmess sni", r1 and r1.nodes[1] and r1.nodes[1].sni == "sni.example.com")
check("singbox trojan pass", r1 and r1.nodes[2] and r1.nodes[2].password == "p1")
-- ---------- V2Ray JSON ----------
local v2ray = [[
{
"outbounds": [
{ "protocol": "vmess", "tag": "v-vmess",
"settings": { "vnext": [ { "address": "5.5.5.5", "port": 443, "users": [ { "id": "vid-1" } ] } ] },
"streamSettings": { "network": "ws", "security": "tls", "tlsSettings": { "serverName": "ws.example.com" } } }
]
}
]]
check("detect v2ray = json", parser.detect(v2ray) == "json")
local r2 = parser.parse(v2ray)
check("v2ray parses", r2 and r2.nodes ~= nil)
check("v2ray 1 node", r2 and #r2.nodes == 1)
check("v2ray uuid", r2 and r2.nodes[1] and r2.nodes[1].uuid == "vid-1")
check("v2ray net ws", r2 and r2.nodes[1] and r2.nodes[1].net == "ws")
check("v2ray sni", r2 and r2.nodes[1] and r2.nodes[1].sni == "ws.example.com")
-- ---------- Clash JSON ----------
local clashjson = [[
{ "proxies": [ { "name": "c-vmess", "type": "vmess", "server": "6.6.6.6", "port": 443, "uuid": "cu1" } ] }
]]
local r3 = parser.parse(clashjson)
check("clash json parses", r3 and r3.nodes ~= nil and #r3.nodes == 1)
check("clash json uuid", r3 and r3.nodes[1] and r3.nodes[1].uuid == "cu1")
-- ---------- Surge 配置 ----------
local surge = [[
[General]
loglevel = notify
[Proxy]
香港 = ss, 8.8.8.8, 8388, encrypt-method=aes-256-gcm, password=sspwd
美西 = vmess, 9.9.9.9, 443, username=vm-uuid, tls=true, sni=vm.example.com, ws=true, ws-path=/wsp
[Proxy Group]
PROXY = select, 香港, 美西
]]
check("detect surge = surge", parser.detect(surge) == "surge")
local r4 = parser.parse(surge)
check("surge parses", r4 and r4.nodes ~= nil)
check("surge 2 nodes", r4 and #r4.nodes == 2)
check("surge ss method", r4 and r4.nodes[1] and r4.nodes[1].method == "aes-256-gcm")
check("surge ss pass", r4 and r4.nodes[1] and r4.nodes[1].password == "sspwd")
check("surge vmess uuid", r4 and r4.nodes[2] and r4.nodes[2].uuid == "vm-uuid")
check("surge vmess net ws", r4 and r4.nodes[2] and r4.nodes[2].net == "ws")
-- ---------- QX 配置 ----------
local qx = [[
[server_local]
shadowsocks=10.0.0.1:8388, method=chacha20-ietf-poly1305, password=qxpwd, tag=QXSS
vmess=10.0.0.2:443, method=none, password=qx-uuid, obfs=ws, obfs-host=qx.example.com, obfs-uri=/qx, tls-verification=true, tag=QXVMess
[policy]
static=节点选择, QXSS, QXVMess, direct
]]
check("detect qx = surge", parser.detect(qx) == "surge")
local r5 = parser.parse(qx)
check("qx parses", r5 and r5.nodes ~= nil)
check("qx 2 nodes", r5 and #r5.nodes == 2)
check("qx ss pass", r5 and r5.nodes[1] and r5.nodes[1].password == "qxpwd")
check("qx vmess uuid", r5 and r5.nodes[2] and r5.nodes[2].uuid == "qx-uuid")
check("qx vmess net ws", r5 and r5.nodes[2] and r5.nodes[2].net == "ws")
print(string.format("\n%d passed, %d failed", passed, failed))
os.exit(failed == 0 and 0 or 1)
@@ -0,0 +1,266 @@
-- parser_json_config_test.lua — JSON 配置解析单元测试
-- 用法:lua5.1 tests/parser_json_config_test.lua
package.path = "./root/usr/share/?.lua;" .. package.path
local json_parser = require("substore.parser_json_config")
local passed, failed = 0, 0
local function check(name, cond)
if cond then
passed = passed + 1
print("PASS " .. name)
else
failed = failed + 1
print("FAIL " .. name)
end
end
-- ---------- Sing-box JSON 解析 ----------
local singbox_json = [[
{
"outbounds": [
{
"tag": "vmess-out",
"type": "vmess",
"server": "1.1.1.1",
"server_port": 443,
"uuid": "abc-123-def",
"security": "tls",
"alter_id": 0,
"tls": {
"server_name": "example.com"
}
},
{
"tag": "vless-out",
"type": "vless",
"server": "2.2.2.2",
"server_port": 443,
"uuid": "vless-uuid-123",
"flow": "xtls-rprx-vision",
"tls": {
"server_name": "example.org"
}
},
{
"tag": "trojan-out",
"type": "trojan",
"server": "3.3.3.3",
"server_port": 443,
"password": "trojan-pass",
"tls": {
"server_name": "trojan.example.com"
}
},
{
"tag": "ss-out",
"type": "shadowsocks",
"server": "4.4.4.4",
"server_port": 8388,
"method": "aes-256-gcm",
"password": "ss-pass"
}
]
}
]]
local singbox_nodes = json_parser.parse_singbox_json(singbox_json)
check("singbox parse result exists", singbox_nodes ~= nil)
check("singbox node count", singbox_nodes and #singbox_nodes == 4)
if singbox_nodes and #singbox_nodes >= 1 then
local n1 = singbox_nodes[1]
check("singbox vmess name", n1.name == "vmess-out")
check("singbox vmess proto", n1.proto == "vmess")
check("singbox vmess server", n1.server == "1.1.1.1")
check("singbox vmess port", n1.port == 443)
check("singbox vmess uuid", n1.uuid == "abc-123-def")
check("singbox vmess security", n1.security == "tls")
check("singbox vmess sni", n1.sni == "example.com")
end
if singbox_nodes and #singbox_nodes >= 2 then
local n2 = singbox_nodes[2]
check("singbox vless name", n2.name == "vless-out")
check("singbox vless proto", n2.proto == "vless")
check("singbox vless server", n2.server == "2.2.2.2")
check("singbox vless port", n2.port == 443)
check("singbox vless uuid", n2.uuid == "vless-uuid-123")
end
if singbox_nodes and #singbox_nodes >= 3 then
local n3 = singbox_nodes[3]
check("singbox trojan name", n3.name == "trojan-out")
check("singbox trojan proto", n3.proto == "trojan")
check("singbox trojan server", n3.server == "3.3.3.3")
check("singbox trojan port", n3.port == 443)
check("singbox trojan password", n3.password == "trojan-pass")
end
if singbox_nodes and #singbox_nodes >= 4 then
local n4 = singbox_nodes[4]
check("singbox ss name", n4.name == "ss-out")
check("singbox ss proto", n4.proto == "shadowsocks")
check("singbox ss server", n4.server == "4.4.4.4")
check("singbox ss port", n4.port == 8388)
check("singbox ss method", n4.method == "aes-256-gcm")
check("singbox ss password", n4.password == "ss-pass")
end
-- ---------- V2Ray JSON 解析 ----------
local v2ray_json = [[
{
"outbounds": [
{
"tag": "vmess-out",
"protocol": "vmess",
"settings": {
"vnext": [
{
"address": "5.5.5.5",
"port": 443,
"users": [
{
"id": "v2ray-vmess-uuid",
"alterId": 0,
"security": "auto"
}
]
}
]
},
"streamSettings": {
"network": "ws",
"security": "tls",
"tlsSettings": {
"serverName": "v2ray.example.com"
}
}
},
{
"tag": "vless-out",
"protocol": "vless",
"settings": {
"vnext": [
{
"address": "6.6.6.6",
"port": 443,
"users": [
{
"id": "v2ray-vless-uuid",
"flow": "xtls-rprx-vision"
}
]
}
]
},
"streamSettings": {
"network": "tcp",
"security": "reality",
"realitySettings": {
"serverName": "reality.example.com"
}
}
}
]
}
]]
local v2ray_nodes = json_parser.parse_v2ray_json(v2ray_json)
check("v2ray parse result exists", v2ray_nodes ~= nil)
check("v2ray node count", v2ray_nodes and #v2ray_nodes == 2)
if v2ray_nodes and #v2ray_nodes >= 1 then
local n1 = v2ray_nodes[1]
check("v2ray vmess name", n1.name == "vmess-out")
check("v2ray vmess proto", n1.proto == "vmess")
check("v2ray vmess server", n1.server == "5.5.5.5")
check("v2ray vmess port", n1.port == 443)
check("v2ray vmess uuid", n1.uuid == "v2ray-vmess-uuid")
check("v2ray vmess net", n1.net == "ws")
end
if v2ray_nodes and #v2ray_nodes >= 2 then
local n2 = v2ray_nodes[2]
check("v2ray vless name", n2.name == "vless-out")
check("v2ray vless proto", n2.proto == "vless")
check("v2ray vless server", n2.server == "6.6.6.6")
check("v2ray vless port", n2.port == 443)
check("v2ray vless uuid", n2.uuid == "v2ray-vless-uuid")
end
-- ---------- Clash JSON 解析 ----------
local clash_json = [[
{
"proxies": [
{
"name": "ClashVMess",
"type": "vmess",
"server": "7.7.7.7",
"port": 443,
"uuid": "clash-vmess-uuid",
"alterId": 0,
"cipher": "auto",
"network": "ws",
"ws-opts": {
"path": "/ws"
}
},
{
"name": "ClashVLESS",
"type": "vless",
"server": "8.8.8.8",
"port": 443,
"uuid": "clash-vless-uuid",
"network": "tcp"
},
{
"name": "ClashTrojan",
"type": "trojan",
"server": "9.9.9.9",
"port": 443,
"password": "clash-trojan-pass",
"sni": "clash.example.com"
}
]
}
]]
local clash_nodes = json_parser.parse_clash_json(clash_json)
check("clash parse result exists", clash_nodes ~= nil)
check("clash node count", clash_nodes and #clash_nodes == 3)
if clash_nodes and #clash_nodes >= 1 then
local n1 = clash_nodes[1]
check("clash vmess name", n1.name == "ClashVMess")
check("clash vmess proto", n1.proto == "vmess")
check("clash vmess server", n1.server == "7.7.7.7")
check("clash vmess port", n1.port == 443)
check("clash vmess uuid", n1.uuid == "clash-vmess-uuid")
check("clash vmess net", n1.net == "ws")
end
if clash_nodes and #clash_nodes >= 2 then
local n2 = clash_nodes[2]
check("clash vless name", n2.name == "ClashVLESS")
check("clash vless proto", n2.proto == "vless")
check("clash vless server", n2.server == "8.8.8.8")
check("clash vless port", n2.port == 443)
check("clash vless uuid", n2.uuid == "clash-vless-uuid")
end
if clash_nodes and #clash_nodes >= 3 then
local n3 = clash_nodes[3]
check("clash trojan name", n3.name == "ClashTrojan")
check("clash trojan proto", n3.proto == "trojan")
check("clash trojan server", n3.server == "9.9.9.9")
check("clash trojan port", n3.port == 443)
check("clash trojan password", n3.password == "clash-trojan-pass")
check("clash trojan sni", n3.sni == "clash.example.com")
end
-- ---------- 结果 ----------
print(string.format("\n%d passed, %d failed", passed, failed))
os.exit(failed == 0 and 0 or 1)
@@ -0,0 +1,130 @@
-- parse_local 表单模式测试:全字段透传 + 类型/别名修正
package.path = "root/usr/share/?.lua;root/usr/share/?/init.lua;" .. package.path
local parser = require("substore.parser")
local util = require("substore.util")
local passed, failed = 0, 0
local function check(name, cond)
if cond then passed = passed + 1; print("PASS " .. name)
else failed = failed + 1; print("FAIL " .. name) end
end
-- 用户实际场景:Clash 风格 vmess ws 节点(经表单录入为 JSON)
local form_json = util.json_encode({
{
type = "vmess",
name = "test-vmess",
server = "attwa10005.cdn.node.a.unicenter7.net",
port = "30849",
uuid = "b109403f-1b9c-3a10-9034-a5b1c2cacbc2",
cipher = "auto",
alterId = "0",
net = "ws",
path = "/009c250b-54f5-43a8-8b18-dccbb5d8c66a.y.live01.m3u8",
["skip-cert-verify"] = "false",
udp = "true",
},
})
local res, err = parser.parse_local(form_json, "form")
check("form parse ok", res ~= nil)
check("form format", res and res.format == "local-form")
check("form node count", res and #res.nodes == 1)
local n = res and res.nodes[1] or {}
check("proto vmess", n.proto == "vmess")
check("name", n.name == "test-vmess")
check("server", n.server == "attwa10005.cdn.node.a.unicenter7.net")
check("port coerced to number", n.port == 30849)
check("uuid", n.uuid == "b109403f-1b9c-3a10-9034-a5b1c2cacbc2")
check("cipher kept", n.cipher == "auto")
check("method alias from cipher", n.method == "auto")
check("alterId coerced", n.alterId == 0)
check("net ws", n.net == "ws")
check("path kept", n.path == "/009c250b-54f5-43a8-8b18-dccbb5d8c66a.y.live01.m3u8")
check("skip-cert-verify bool false", n["skip-cert-verify"] == false)
check("skip_cert_verify alias", n.skip_cert_verify == false)
check("udp bool true", n.udp == true)
-- network 别名 → net
local res2 = parser.parse_local(util.json_encode({
{ type = "vless", server = "a.com", port = "443", uuid = "u", network = "ws", path = "/p" },
}), "form")
local n2 = res2 and res2.nodes[1] or {}
check("network alias to net", n2.net == "ws")
check("network key removed", n2.network == nil)
check("path kept vless", n2.path == "/p")
-- ssr 参数别名
local res3 = parser.parse_local(util.json_encode({
{ type = "ssr", server = "b.com", port = "8388", password = "p", cipher = "aes-256-cfb",
protocol = "auth_aes128_md5", obfs = "tls1.2_ticket_auth",
["obfs-param"] = "op", ["protocol-param"] = "pp" },
}), "form")
local n3 = res3 and res3.nodes[1] or {}
check("ssr obfs_param alias", n3.obfs_param == "op")
check("ssr protocol_param alias", n3.protocol_param == "pp")
-- wireguard kebab-case 字段透传
local res4 = parser.parse_local(util.json_encode({
{ type = "wireguard", server = "c.com", port = "51820",
["private-key"] = "pk", ["peer-public-key"] = "ppk" },
}), "form")
local n4 = res4 and res4.nodes[1] or {}
check("wireguard private-key kept", n4["private-key"] == "pk")
check("wireguard peer-public-key kept", n4["peer-public-key"] == "ppk")
-- 无名节点自动命名
local res5 = parser.parse_local(util.json_encode({
{ type = "trojan", server = "d.com", port = "443", password = "x" },
}), "form")
local n5 = res5 and res5.nodes[1] or {}
check("auto name server:port", n5.name == "d.com:443")
-- 非 JSON 返回错误
local res6, err6 = parser.parse_local("not json", "form")
check("invalid json error", res6 == nil and err6 ~= nil)
-- hy2 混淆:表单透传 → URI 输出 → URI 解析回环
local res7 = parser.parse_local(util.json_encode({
{ type = "hysteria2", server = "hy2.com", port = "443", password = "pw",
sni = "hy2.com", obfs = "salamander", ["obfs-password"] = "secret" },
}), "form")
local n7 = res7 and res7.nodes[1] or {}
check("hy2 obfs passthrough", n7.obfs == "salamander")
check("hy2 obfs-password passthrough", n7["obfs-password"] == "secret")
local output_uri = require("substore.output_uri")
local hy2_link = output_uri.to_share_uri and output_uri.to_share_uri(n7) or nil
check("hy2 uri generated", type(hy2_link) == "string" and hy2_link:match("^hysteria2://") ~= nil)
check("hy2 uri has obfs", hy2_link and hy2_link:find("obfs=salamander", 1, true) ~= nil)
check("hy2 uri has obfs-password", hy2_link and hy2_link:find("obfs%-password=secret") ~= nil)
-- URI 回读
local res8 = hy2_link and parser.parse(hy2_link) or nil
local n8 = res8 and res8.nodes and res8.nodes[1] or {}
check("hy2 uri roundtrip obfs", n8.obfs == "salamander")
check("hy2 uri roundtrip obfs-password", n8["obfs-password"] == "secret")
-- clash_meta / sing-box 输出含混淆
local output_clash = require("substore.output_clash_meta")
local output_singbox = require("substore.output_singbox")
local cm = output_clash.generate and output_clash.generate({ n7 }) or nil
check("clash_meta hy2 obfs", type(cm) == "string" and cm:find("obfs: salamander", 1, true) ~= nil)
check("clash_meta hy2 obfs-password", type(cm) == "string" and cm:find("obfs%-password: secret") ~= nil)
local sb = output_singbox.to_outbound and output_singbox.to_outbound(n7) or nil
check("singbox hy2 obfs type", type(sb) == "table" and sb.obfs and sb.obfs.type == "salamander")
check("singbox hy2 obfs password", type(sb) == "table" and sb.obfs and sb.obfs.password == "secret")
-- vmess headerType / tls 透传
local res9 = parser.parse_local(util.json_encode({
{ type = "vmess", server = "v.com", port = "80", uuid = "u", net = "tcp",
headerType = "http", host = "a.com", tls = "tls" },
}), "form")
local n9 = res9 and res9.nodes[1] or {}
check("vmess headerType passthrough", n9.headerType == "http")
check("vmess tls passthrough", n9.tls == "tls")
check("vmess type key removed", n9.type == nil)
print(string.format("%d passed, %d failed", passed, failed))
os.exit(failed == 0 and 0 or 1)
@@ -0,0 +1,54 @@
-- parser_local_link_test.lua — 局域网订阅链接解析单元测试
-- 用法:lua5.1 tests/parser_local_link_test.lua
package.path = "./root/usr/share/?.lua;" .. package.path
local parser = require("substore.parser")
local passed, failed = 0, 0
local function check(name, cond)
if cond then
passed = passed + 1
print("PASS " .. name)
else
failed = failed + 1
print("FAIL " .. name)
end
end
-- ---------- 局域网链接检测 ----------
local local_url_1 = "http://192.168.5.15:31013/arthur/download/FeijiCloud?target=ClashMeta"
local local_url_2 = "http://192.168.1.100:8080/api/subscription?name=MySub&target=Surge"
local local_url_3 = "https://example.com/download" -- 非局域网,应该不识别
check("detect local link 192.168", parser.detect_local_link(local_url_1) ~= nil)
check("detect local link 192.168.1", parser.detect_local_link(local_url_2) ~= nil)
check("detect non-local link", parser.detect_local_link(local_url_3) == nil)
-- ---------- 解析局域网链接 ----------
local result1 = parser.parse_local_link(local_url_1)
check("parse_local_link host", result1 and result1.host == "192.168.5.15")
check("parse_local_link port", result1 and result1.port == "31013")
check("parse_local_link path", result1 and result1.path == "/arthur/download/FeijiCloud")
check("parse_local_link target", result1 and result1.target == "ClashMeta")
check("parse_local_link name", result1 and result1.name == "FeijiCloud")
check("parse_local_link user", result1 and result1.user == "arthur")
local result2 = parser.parse_local_link(local_url_2)
check("parse_local_link 2 host", result2 and result2.host == "192.168.1.100")
check("parse_local_link 2 port", result2 and result2.port == "8080")
check("parse_local_link 2 target", result2 and result2.target == "Surge")
check("parse_local_link 2 name", result2 and result2.name == "MySub")
-- 测试带查询参数的链接
local local_url_3 = "http://10.0.0.5:9000/sub?target=SingBox&name=TestSub&uid=user123"
local result3 = parser.parse_local_link(local_url_3)
check("parse_local_link 10.0.0", result3 and result3.host == "10.0.0.5")
check("parse_local_link target SingBox", result3 and result3.target == "SingBox")
check("parse_local_link name param", result3 and result3.name == "TestSub")
check("parse_local_link user param", result3 and result3.user == "user123")
-- ---------- 结果 ----------
print(string.format("\n%d passed, %d failed", passed, failed))
os.exit(failed == 0 and 0 or 1)
@@ -0,0 +1,121 @@
-- parser_yaml_test.lua — YAML 解析单元测试
-- 用法:lua5.1 tests/parser_yaml_test.lua
package.path = "./root/usr/share/?.lua;" .. package.path
local parser = require("substore.parser")
local passed, failed = 0, 0
local function check(name, cond)
if cond then
passed = passed + 1
print("PASS " .. name)
else
failed = failed + 1
print("FAIL " .. name)
end
end
-- ---------- YAML 检测 ----------
local yaml_clash = [[
proxies:
- name: TestVMess
type: vmess
server: 1.1.1.1
port: 443
uuid: abc-123
alterId: 0
cipher: auto
]]
check("detect yaml proxies", parser.detect(yaml_clash) == "yaml")
local yaml_outbounds = [[
outbounds:
- tag: direct
type: http
]]
check("detect yaml outbounds", parser.detect(yaml_outbounds) == "yaml")
-- ---------- YAML 解析 ----------
local yaml_sample = [[
proxies:
- name: TestVMess
type: vmess
server: 1.1.1.1
port: 443
uuid: abc-123
alterId: 0
cipher: auto
network: ws
ws-opts:
path: /ws
- name: TestSS
type: ss
server: 2.2.2.2
port: 8388
cipher: aes-256-gcm
password: pass123
- name: TestTrojan
type: trojan
server: 3.3.3.3
port: 443
password: trojan-pass
sni: example.com
]]
local result = parser.parse(yaml_sample)
check("parse yaml result exists", result ~= nil)
check("parse yaml format", result and result.format == "yaml")
check("parse yaml node count", result and #result.nodes == 3)
if result and #result.nodes >= 1 then
local n1 = result.nodes[1]
check("yaml vmess name", n1.name == "TestVMess")
check("yaml vmess proto", n1.proto == "vmess")
check("yaml vmess server", n1.server == "1.1.1.1")
check("yaml vmess port", n1.port == 443)
check("yaml vmess uuid", n1.uuid == "abc-123")
check("yaml vmess net", n1.net == "ws")
end
if result and #result.nodes >= 2 then
local n2 = result.nodes[2]
check("yaml ss name", n2.name == "TestSS")
check("yaml ss proto", n2.proto == "shadowsocks")
check("yaml ss server", n2.server == "2.2.2.2")
check("yaml ss port", n2.port == 8388)
check("yaml ss method", n2.method == "aes-256-gcm")
check("yaml ss password", n2.password == "pass123")
end
if result and #result.nodes >= 3 then
local n3 = result.nodes[3]
check("yaml trojan name", n3.name == "TestTrojan")
check("yaml trojan proto", n3.proto == "trojan")
check("yaml trojan server", n3.server == "3.3.3.3")
check("yaml trojan port", n3.port == 443)
check("yaml trojan password", n3.password == "trojan-pass")
check("yaml trojan sni", n3.sni == "example.com")
end
-- ---------- YAML parse_yaml 直接调用 ----------
local yaml_simple = [[
proxies:
- name: SimpleNode
type: vless
server: 4.4.4.4
port: 443
uuid: vless-uuid
]]
local nodes = parser.parse_yaml(yaml_simple)
check("parse_yaml direct", nodes and #nodes == 1)
check("parse_yaml direct name", nodes and nodes[1].name == "SimpleNode")
check("parse_yaml direct proto", nodes and nodes[1].proto == "vless")
-- ---------- 结果 ----------
print(string.format("\n%d passed, %d failed", passed, failed))
os.exit(failed == 0 and 0 or 1)
@@ -0,0 +1,110 @@
-- 简单的 YAML 解析逻辑验证(无依赖)
-- 验证解析器的核心逻辑
-- 模拟 util.trim
local function trim(s)
return s:match("^%s*(.-)%s*$")
end
-- 模拟 split_lines
local function split_lines(content)
local out = {}
for line in content:gmatch("[^\r\n]+") do
out[#out + 1] = line
end
return out
end
-- 测试 YAML 解析逻辑
local function test_yaml_parsing()
local yaml_sample = [[
proxies:
- name: TestVMess
type: vmess
server: 1.1.1.1
port: 443
uuid: abc-123
- name: TestSS
type: ss
server: 2.2.2.2
port: 8388
cipher: aes-256-gcm
password: pass123
]]
local lines = split_lines(yaml_sample)
local raw_nodes = {}
local current_node = nil
local in_proxies = false
local proxies_indent = 0
for i, line in ipairs(lines) do
local trimmed = trim(line)
if trimmed == "" or trimmed:sub(1, 1) == "#" then
elseif trimmed:match("^proxies:%s*$") or trimmed:match("^outbounds:%s*$") then
in_proxies = true
proxies_indent = line:match("^%s*") and #line:match("^%s*") or 0
current_node = nil
elseif in_proxies then
local list_match = line:match("^%s*%-%s*(.*)$")
if list_match then
if current_node then
raw_nodes[#raw_nodes + 1] = current_node
end
current_node = {}
if list_match:match("^([^:]+):%s*(.+)$") then
local k, v = list_match:match("^([^:]+):%s*(.+)$")
current_node[k] = trim(v)
end
else
local indent = line:match("^%s*") and #line:match("^%s*") or 0
if current_node and indent > proxies_indent then
local k, v = trimmed:match("^([^:]+):%s*(.+)$")
if k then
k = trim(k)
v = trim(v)
v = v:gsub('^["\'](.*)["\']$', '%1')
current_node[k] = v
end
else
if current_node then
raw_nodes[#raw_nodes + 1] = current_node
current_node = nil
end
in_proxies = false
end
end
end
end
if current_node then
raw_nodes[#raw_nodes + 1] = current_node
end
print("Parsed nodes: " .. #raw_nodes)
for i, n in ipairs(raw_nodes) do
print("Node " .. i .. ":")
for k, v in pairs(n) do
print(" " .. k .. " = " .. tostring(v))
end
end
-- 验证
assert(#raw_nodes == 2, "Should have 2 nodes")
assert(raw_nodes[1].name == "TestVMess", "First node name")
assert(raw_nodes[1].type == "vmess", "First node type")
assert(raw_nodes[1].server == "1.1.1.1", "First node server")
assert(raw_nodes[1].port == "443", "First node port")
assert(raw_nodes[1].uuid == "abc-123", "First node uuid")
assert(raw_nodes[2].name == "TestSS", "Second node name")
assert(raw_nodes[2].type == "ss", "Second node type")
assert(raw_nodes[2].server == "2.2.2.2", "Second node server")
assert(raw_nodes[2].port == "8388", "Second node port")
assert(raw_nodes[2].cipher == "aes-256-gcm", "Second node cipher")
assert(raw_nodes[2].password == "pass123", "Second node password")
print("All tests passed!")
end
test_yaml_parsing()
+46
View File
@@ -0,0 +1,46 @@
-- probe_test.lua — 节点探测模块单元测试
-- 用法:lua5.1 tests/probe_test.lua
package.path = "./root/usr/share/?.lua;" .. package.path
local probe = require("substore.probe")
local passed, failed = 0, 0
local function check(name, cond)
if cond then passed = passed + 1; print("PASS " .. name)
else failed = failed + 1; print("FAIL " .. name) end
end
-- ---------- 解析函数 ----------
check("parse_ping time", probe.parse_ping("64 bytes from 1.1.1.1: seq=0 ttl=54 time=12.345 ms") == 12.345)
check("parse_ping loss", probe.parse_ping("100% packet loss") == nil)
check("parse_ping empty", probe.parse_ping("") == nil)
check("parse_delta ok", math.abs((probe.parse_delta("100.00 100.05") or 0) - 50) < 1)
check("parse_delta empty", probe.parse_delta("") == nil)
check("parse_delta single", probe.parse_delta("100.00") == nil)
-- ---------- 注入防护(在启动子进程前被拒绝,返回 nil)----------
check("ping empty host", probe.ping("") == nil)
check("ping injection", probe.ping("1.1.1.1; rm -rf /") == nil)
check("tcping bad port", probe.tcping("1.1.1.1", "abc") == nil)
check("tcping port overflow", probe.tcping("1.1.1.1", 99999) == nil)
check("url injection", probe.url_test("example.com; touch /tmp/x", 80) == nil)
-- ---------- 本地回环 ----------
check("ping loopback numeric", type(probe.ping("127.0.0.1")) == "number")
check("tcping closed port nil", probe.tcping("127.0.0.1", 1) == nil)
check("url closed port nil", probe.url_test("127.0.0.1", 1) == nil)
-- ---------- 批量探测结果结构 ----------
local r = probe.probe({
{ name = "a", server = "127.0.0.1", port = 80 },
{ name = "bad", server = "bad host", port = 80 },
{ name = "b", server = "127.0.0.1", port = 1 },
}, "ping")
check("probe returns 3 results", #r == 3)
check("probe name preserved", r[1].name == "a" and r[2].name == "bad" and r[3].name == "b")
check("probe invalid host latency nil", r[2].latency == nil)
check("probe shape fields", r[1].server == "127.0.0.1" and r[1].port == 80)
print(string.format("\n%d passed, %d failed", passed, failed))
os.exit(failed == 0 and 0 or 1)
@@ -0,0 +1,152 @@
-- protocol_support_test.lua — hysteria2 / tuic / wireguard 导入、JSON 配置导入放开、Surge 家族输出单元测试
-- 用法:lua5.1 tests/protocol_support_test.lua
package.path = "./root/usr/share/?.lua;" .. package.path
local parser = require("substore.parser")
local parser_json_config = require("substore.parser_json_config")
local output = require("substore.output")
local output_uri = require("substore.output_uri")
local util = require("substore.util")
local passed, failed = 0, 0
local function check(name, cond)
if cond then
passed = passed + 1
print("PASS " .. name)
else
failed = failed + 1
print("FAIL " .. name)
end
end
-- ---------- hysteria2:// 分享链接导入 ----------
local hy2 = parser.parse_uri("hysteria2://my-secret@1.2.3.4:443/?sni=sni.example.com&insecure=1#HY2节点")
check("hy2 parses", hy2 ~= nil)
check("hy2 proto", hy2 and hy2.proto == "hysteria2")
check("hy2 server", hy2 and hy2.server == "1.2.3.4")
check("hy2 port", hy2 and hy2.port == 443)
check("hy2 password", hy2 and hy2.password == "my-secret")
check("hy2 sni", hy2 and hy2.sni == "sni.example.com")
check("hy2 insecure", hy2 and hy2.insecure == "1")
check("hy2 name", hy2 and hy2.name == "HY2节点")
-- ---------- tuic:// 分享链接导入 ----------
local tuic = parser.parse_uri("tuic://uuid-1234:tuic-pass@5.6.7.8:8443/?congestion_control=bbr&alpn=h3&sni=t.example.com#TUIC节点")
check("tuic parses", tuic ~= nil)
check("tuic proto", tuic and tuic.proto == "tuic")
check("tuic server", tuic and tuic.server == "5.6.7.8")
check("tuic port", tuic and tuic.port == 8443)
check("tuic uuid", tuic and tuic.uuid == "uuid-1234")
check("tuic password", tuic and tuic.password == "tuic-pass")
check("tuic congestion_control", tuic and tuic.congestion_control == "bbr")
check("tuic alpn", tuic and tuic.alpn == "h3")
check("tuic sni", tuic and tuic.sni == "t.example.com")
check("tuic name", tuic and tuic.name == "TUIC节点")
-- ---------- wireguard:// 导入(固定 base64 外部样本,非本实现生成) ----------
-- base64 内容:{"server":"10.0.0.9","port":"51820","private-key":"privkey123","peer-public-key":"pubkey456","preshared-key":"psk789","mtu":"1420"}
local wg_uri = "wireguard://eyJzZXJ2ZXIiOiIxMC4wLjAuOSIsInBvcnQiOiI1MTgyMCIsInByaXZhdGUta2V5IjoicHJpdmtleTEyMyIsInBlZXItcHVibGljLWtleSI6InB1YmtleTQ1NiIsInByZXNoYXJlZC1rZXkiOiJwc2s3ODkiLCJtdHUiOiIxNDIwIn0=#WG节点"
check("detect wg = uri", parser.detect(wg_uri) == "uri")
local wg = parser.parse_uri(wg_uri)
check("wg parses", wg ~= nil)
check("wg proto", wg and wg.proto == "wireguard")
check("wg server", wg and wg.server == "10.0.0.9")
check("wg port", wg and wg.port == 51820)
check("wg private-key", wg and wg["private-key"] == "privkey123")
check("wg peer-public-key", wg and wg["peer-public-key"] == "pubkey456")
check("wg preshared-key", wg and wg["preshared-key"] == "psk789")
check("wg mtu", wg and wg.mtu == 1420)
check("wg name", wg and wg.name == "WG节点")
-- ---------- 订阅(URI 列表 / base64)含 hy2 / tuic / wg ----------
local sub = "hysteria2://p@1.1.1.1:8443#A\ntuic://u:p@2.2.2.2:8443#B\nvless://uuid1@3.3.3.3:443#C"
local res = parser.parse(sub)
check("sub format uri", res and res.format == "uri")
check("sub 3 nodes", res and #res.nodes == 3)
local b64sub = util.base64_encode("hysteria2://p@1.1.1.1:8443#A\ntuic://u:p@2.2.2.2:8443#B")
local res2 = parser.parse(b64sub)
check("base64 sub 2 nodes", res2 and #res2.nodes == 2)
check("base64 sub proto hy2", res2 and res2.nodes[1] and res2.nodes[1].proto == "hysteria2")
-- ---------- URI 输出(Shadowrocket / V2Ray URI)含 hy2 / tuic / wg ----------
local hy2node = { proto = "hysteria2", name = "HY", server = "1.1.1.1", port = 443, password = "p", sni = "s.example.com" }
local tuicnode = { proto = "tuic", name = "TC", server = "2.2.2.2", port = 8443, uuid = "u", password = "p" }
local wgnode = { proto = "wireguard", name = "WG", server = "10.0.0.9", port = 51820,
["private-key"] = "pk", ["peer-public-key"] = "ppk", ["preshared-key"] = "psk" }
local uri_out = output.generate({ hy2node, tuicnode, wgnode }, "v2rayuri")
check("uri has hysteria2", uri_out and uri_out:find("hysteria2://") ~= nil)
check("uri has tuic", uri_out and uri_out:find("tuic://") ~= nil)
check("uri has wireguard", uri_out and uri_out:find("wireguard://") ~= nil)
local rocket = output.generate({ hy2node, tuicnode, wgnode }, "shadowrocket")
local decoded = util.base64_decode(rocket)
check("shadowrocket has hysteria2", decoded:find("hysteria2://") ~= nil)
check("shadowrocket has wireguard", decoded:find("wireguard://") ~= nil)
-- ---------- wireguard 回环(输出 -> 解析) ----------
local wg_round = parser.parse_uri(uri_out:match("wireguard://[^\n]*"))
check("wg roundtrip proto", wg_round and wg_round.proto == "wireguard")
check("wg roundtrip server", wg_round and wg_round.server == "10.0.0.9")
check("wg roundtrip private", wg_round and wg_round["private-key"] == "pk")
check("wg roundtrip peer", wg_round and wg_round["peer-public-key"] == "ppk")
-- ---------- JSON 配置导入放开:sing-box JSON ----------
local singbox = [[
{
"outbounds": [
{ "type": "hysteria2", "tag": "sb-hy2", "server": "1.1.1.1", "server_port": 443, "password": "hy2pw", "tls": { "server_name": "h.sni.com" } },
{ "type": "tuic", "tag": "sb-tuic", "server": "2.2.2.2", "server_port": 8443, "uuid": "sb-uuid", "password": "sb-tuicpw", "congestion_control": "bbr" },
{ "type": "wireguard", "tag": "sb-wg", "server": "3.3.3.3", "server_port": 51820, "private_key": "sb-priv", "peer_public_key": "sb-pub", "preshared_key": "sb-psk", "mtu": 1400 },
{ "type": "socks", "tag": "sb-socks", "server": "4.4.4.4", "server_port": 1080, "username": "suser", "password": "spass" }
]
}
]]
local sbnodes = parser_json_config.parse_singbox_json(singbox)
check("singbox 4 nodes", sbnodes and #sbnodes == 4)
check("singbox hy2", sbnodes and sbnodes[1] and sbnodes[1].proto == "hysteria2" and sbnodes[1].password == "hy2pw" and sbnodes[1].sni == "h.sni.com")
check("singbox tuic", sbnodes and sbnodes[2] and sbnodes[2].proto == "tuic" and sbnodes[2].uuid == "sb-uuid" and sbnodes[2].password == "sb-tuicpw")
check("singbox wg kebab", sbnodes and sbnodes[3] and sbnodes[3].proto == "wireguard" and sbnodes[3]["private-key"] == "sb-priv" and sbnodes[3]["peer-public-key"] == "sb-pub" and sbnodes[3]["preshared-key"] == "sb-psk")
check("singbox socks", sbnodes and sbnodes[4] and sbnodes[4].proto == "socks" and sbnodes[4].username == "suser" and sbnodes[4].password == "spass")
-- ---------- JSON 配置导入放开:Clash JSON(含 ssr) ----------
local clashjson = [[
{
"proxies": [
{ "name": "c-ssr", "type": "ssr", "server": "1.1.1.1", "port": 8388, "cipher": "aes-128-cfb", "password": "ssrpw", "protocol": "auth_sha1_v4", "obfs": "http_simple", "obfs-param": "obfshost.com" },
{ "name": "c-hy2", "type": "hysteria2", "server": "2.2.2.2", "port": 443, "password": "hy2pw", "sni": "h2.example.com" },
{ "name": "c-tuic", "type": "tuic", "server": "3.3.3.3", "port": 8443, "uuid": "c-uuid", "password": "c-tuicpw" },
{ "name": "c-wg", "type": "wireguard", "server": "4.4.4.4", "port": 51820, "private-key": "c-priv", "peer-public-key": "c-pub", "preshared-key": "c-psk" },
{ "name": "c-socks", "type": "socks5", "server": "5.5.5.5", "port": 1080, "username": "cu", "password": "cp" }
]
}
]]
local cnodes = parser_json_config.parse_clash_json(clashjson)
check("clash json 5 nodes", cnodes and #cnodes == 5)
check("clash json ssr", cnodes and cnodes[1] and cnodes[1].proto == "ssr" and cnodes[1].method == "aes-128-cfb" and cnodes[1].protocol == "auth_sha1_v4" and cnodes[1].obfs == "http_simple" and cnodes[1].obfs_param == "obfshost.com")
check("clash json hy2", cnodes and cnodes[2] and cnodes[2].proto == "hysteria2" and cnodes[2].password == "hy2pw")
check("clash json tuic", cnodes and cnodes[3] and cnodes[3].proto == "tuic" and cnodes[3].uuid == "c-uuid")
check("clash json wg", cnodes and cnodes[4] and cnodes[4].proto == "wireguard" and cnodes[4]["private-key"] == "c-priv" and cnodes[4]["peer-public-key"] == "c-pub")
check("clash json socks5->socks", cnodes and cnodes[5] and cnodes[5].proto == "socks" and cnodes[5].username == "cu" and cnodes[5].password == "cp")
-- ---------- Surge 家族:tuic 输出 / wireguard 丢弃 ----------
local surge = output.generate({ tuicnode, wgnode }, "surge")
check("surge has tuic line", surge and surge:find("tuic, 2%.2%.2%.2, 8443") ~= nil)
check("surge tuic username", surge and surge:find("username=u") ~= nil)
check("surge drops wireguard", surge and surge:find("10%.0%.0%.9") == nil and surge:find("wireguard") == nil)
local loon = output.generate({ wgnode }, "loon")
check("loon drops wireguard too", loon and loon:find("wireguard") == nil)
-- ---------- sing-box 输出 wireguard 使用 kebab 字段(导入后可正确输出) ----------
local sb_out = output.generate({ wgnode }, "singbox")
local sb_decoded = util.json_decode(sb_out)
check("singbox wg private_key", sb_decoded and sb_decoded.outbounds[1] and sb_decoded.outbounds[1].private_key == "pk")
check("singbox wg peer_public_key", sb_decoded and sb_decoded.outbounds[1] and sb_decoded.outbounds[1].peer_public_key == "ppk")
-- ---------- 结果 ----------
print(string.format("\n%d passed, %d failed", passed, failed))
os.exit(failed == 0 and 0 or 1)
+125
View File
@@ -0,0 +1,125 @@
-- run_tests.lua — 纯 Lua 核心库单元测试(在目标设备或装有 Lua 5.1 的环境运行)
-- 用法:lua5.1 tests/run_tests.lua
-- 说明:本仓库开发环境无 Lua 运行时,脚本未在本地执行;请在有 Lua 的设备/环境验证。
package.path = "./root/usr/share/?.lua;" .. package.path
local util = require("substore.util")
local parser = require("substore.parser")
local node = require("substore.node")
local passed, failed = 0, 0
local function check(name, cond)
if cond then
passed = passed + 1
print("PASS " .. name)
else
failed = failed + 1
print("FAIL " .. name)
end
end
local function eq(a, b)
if type(a) ~= type(b) then return false end
if type(a) == "table" then
for k, v in pairs(a) do if not eq(v, b[k]) then return false end end
for k in pairs(b) do if a[k] == nil then return false end end
return true
end
return a == b
end
-- ---------- base64 ----------
check("b64 decode hello", util.base64_decode("aGVsbG8=") == "hello")
check("b64 encode hello", util.base64_encode("hello") == "aGVsbG8=")
check("b64 roundtrip unicode", util.base64_decode(util.base64_encode("机场节点中文")) == "机场节点中文")
check("b64 paddingless", util.base64_decode("aGVsbG8") == "hello")
check("b64 skip whitespace", util.base64_decode("aGVs\nbG8=") == "hello")
-- ---------- JSON ----------
local sample = { _seq = 2, items = { s1 = { name = "机场", url = "https://x.test/sub", node_count = 3, enabled = true, last_update = nil } } }
local encoded = util.json_encode(sample)
local decoded = util.json_decode(encoded)
check("json roundtrip", eq(decoded, sample))
check("json decode unicode escape", util.json_decode('{"name":"\\u4e2d\\u6587"}').name == "中文")
check("json array", #util.json_decode("[1,2,3]") == 3)
check("json nested", util.json_decode('{"a":{"b":[true,false,null,1.5]}}').a.b[4] == 1.5)
-- ---------- URL ----------
local h1, p1 = util.split_hostport("1.2.3.4:443")
check("split_hostport v4", h1 == "1.2.3.4" and p1 == "443")
local h2, p2 = util.split_hostport("[::1]:80")
check("split_hostport v6", h2 == "::1" and p2 == "80")
check("url_extract", util.url_extract("?type=tcp&security=none", "security") == "none")
check("url_decode", util.url_decode("%E6%9C%BA%E5%9C%BA") == "机场")
-- ---------- node.normalize ----------
check("normalize ss alias", node.normalize({ proto = "ss", name = "x", server = "h", port = 1 }).proto == "shadowsocks")
local nm = node.normalize({ proto = "vmess", server = "h", port = 443 })
check("normalize default net", nm.net == "tcp")
check("normalize default name", node.normalize({ proto = "vless", server = "h" }).name == "h:")
-- ---------- parser.parse_uri ----------
local vmess_json = util.json_encode({ add = "1.1.1.1", port = 443, id = "uuid1", net = "ws", scy = "auto", ps = "测试节点" })
local n1 = parser.parse_uri("vmess://" .. util.base64_encode(vmess_json))
check("vmess classic", n1 and n1.server == "1.1.1.1" and n1.port == 443 and n1.uuid == "uuid1" and n1.net == "ws")
check("vmess name", n1 and n1.name == "测试节点")
local n2 = parser.parse_uri("vless://uuid1@1.1.1.1:443?type=ws&security=tls&sni=example.com#VLESS")
check("vless", n2 and n2.proto == "vless" and n2.uuid == "uuid1" and n2.server == "1.1.1.1" and n2.net == "ws" and n2.sni == "example.com" and n2.name == "VLESS")
local n3 = parser.parse_uri("trojan://pass1@2.2.2.2:443?security=tls&sni=abc.com#TROJAN")
check("trojan", n3 and n3.password == "pass1" and n3.server == "2.2.2.2" and n3.sni == "abc.com")
local n4 = parser.parse_uri("ss://" .. util.base64_encode("aes-256-gcm:password1") .. "@3.3.3.3:8388#SS")
check("ss b64-userinfo", n4 and n4.method == "aes-256-gcm" and n4.password == "password1" and n4.server == "3.3.3.3" and n4.port == 8388)
local n5 = parser.parse_uri("ss://aes-256-gcm:password2@4.4.4.4:8388#SS2")
check("ss plain", n5 and n5.method == "aes-256-gcm" and n5.password == "password2" and n5.server == "4.4.4.4")
local n6 = parser.parse_uri("ss://" .. util.base64_encode("aes-256-gcm:password3") .. "@5.5.5.5:8388/?plugin=obfs-local%3Bobfs%3Dhttp")
check("ss plugin", n6 and n6.plugin ~= nil and n6.server == "5.5.5.5")
-- ss://base64(whole "method:password@host:port")#name
local n7 = parser.parse_uri("ss://" .. util.base64_encode("chacha20-ietf-poly1305:passw@6.6.6.6:443") .. "#SSW")
check("ss b64-whole", n7 and n7.method == "chacha20-ietf-poly1305" and n7.password == "passw" and n7.server == "6.6.6.6" and n7.port == 443)
check("unsupported proto", parser.parse_uri("foobar://x") == nil)
-- ---------- parser.detect / parse ----------
check("detect base64", parser.detect("aGVsbG8vZGVjb2RlZC1zc3Nzc3Nzc3M=") == "base64")
check("detect uri", parser.detect("vless://uuid1@1.1.1.1:443#x") == "uri")
check("detect json", parser.detect('{"server":"1.1.1.1","port":443}') == "json")
check("detect unknown", parser.detect("garbage content here") == "unknown")
local uri_sub = "vless://uuid1@1.1.1.1:443#A\ntrojan://pass@2.2.2.2:443#B\nss://aes-128-gcm:pass@3.3.3.3:8388#C"
local res = parser.parse(uri_sub)
check("parse uri sub count", res and #res.nodes == 3)
check("parse uri sub format", res and res.format == "uri")
-- base64 订阅:内容是若干 URI 的 base64
local plain = "vless://uuid1@1.1.1.1:443#A\nss://aes-128-gcm:pass@2.2.2.2:8388#B"
local res2 = parser.parse(util.base64_encode(plain))
check("parse base64 sub count", res2 and #res2.nodes == 2)
check("parse base64 sub format", res2 and res2.format == "base64")
-- base64url(URL-safe base64:- _ 替代 + /,无 padding)订阅
check("detect base64url literal", parser.detect("eyJ-abc_def") == "base64")
local vmess_link = "vmess://" .. util.base64_encode(util.json_encode({ v = "2", ps = "B64U", add = "9.9.9.9", port = "443", id = "u-1", aid = "0", net = "tcp" }))
local b64u_sub = util.base64_url_encode(vmess_link .. "\nvless://uuid2@8.8.8.8:443#V")
check("detect base64url sub", parser.detect(b64u_sub) == "base64")
local b64u_res = parser.parse(b64u_sub)
check("parse base64url count", b64u_res and #b64u_res.nodes == 2)
check("parse base64url proto", b64u_res and b64u_res.nodes[1] and b64u_res.nodes[1].proto == "vmess")
check("parse base64url server", b64u_res and b64u_res.nodes[1] and b64u_res.nodes[1].server == "9.9.9.9")
-- UTF-8 BOM 前缀的订阅
local bom_sub = "\239\187\191" .. util.base64_encode(vmess_link)
check("detect BOM prefix", parser.detect(bom_sub) == "base64")
local bom_res = parser.parse(bom_sub)
check("parse BOM count", bom_res and #bom_res.nodes == 1)
-- ---------- 结果 ----------
print(string.format("\n%d passed, %d failed", passed, failed))
os.exit(failed == 0 and 0 or 1)
+99
View File
@@ -0,0 +1,99 @@
-- ssr_test.lua — SSR(ShadowsocksR)解析与输出单元测试
-- 用法:lua5.1 tests/ssr_test.lua
package.path = "./root/usr/share/?.lua;" .. package.path
local util = require("substore.util")
local parser = require("substore.parser")
local output = require("substore.output")
local output_uri = require("substore.output_uri")
local passed, failed = 0, 0
local function check(name, cond)
if cond then
passed = passed + 1
print("PASS " .. name)
else
failed = failed + 1
print("FAIL " .. name)
end
end
-- 独立构造的已知 SSR 分享链接(由 Python base64 生成,非本实现生成,避免循环验证):
-- server=127.0.0.1 port=8388 protocol=auth_aes128_md5 method=aes-128-cfb obfs=http_simple
-- password=test-password obfsparam=download.windowsupdate.com remarks=测试节点 group=测试组
local link = "ssr://MTI3LjAuMC4xOjgzODg6YXV0aF9hZXMxMjhfbWQ1OmFlcy0xMjgtY2ZiOmh0dHBfc2ltcGxlOmRHVnpkQzF3WVhOemQyOXlaQT09Lz9vYmZzcGFyYW09Wkc5M2JteHZZV1F1ZDJsdVpHOTNjM1Z3WkdGMFpTNWpiMjAmcmVtYXJrcz01cldMNkstVjZJcUM1NEs1Jmdyb3VwPTVyV0w2Sy1WNTd1RQ=="
check("detect ssr = uri", parser.detect(link) == "uri")
local n = parser.parse_uri(link)
check("ssr parses", n ~= nil)
check("ssr proto", n and n.proto == "ssr")
check("ssr server", n and n.server == "127.0.0.1")
check("ssr port", n and n.port == 8388)
check("ssr protocol", n and n.protocol == "auth_aes128_md5")
check("ssr method", n and n.method == "aes-128-cfb")
check("ssr obfs", n and n.obfs == "http_simple")
check("ssr password", n and n.password == "test-password")
check("ssr obfs_param", n and n.obfs_param == "download.windowsupdate.com")
check("ssr name", n and n.name == "测试节点")
check("ssr group", n and n.group == "测试组")
-- ---------- 订阅解析(URI 列表含 ssr) ----------
local res = parser.parse(link .. "\nvless://uuid1@1.1.1.1:443#V")
check("parse ssr sub count", res and res.nodes and #res.nodes == 2)
check("parse ssr sub proto", res and res.nodes[1] and res.nodes[1].proto == "ssr")
-- ---------- ssr:// 分享链接回环 ----------
local re = output_uri.to_ssr_uri(n)
check("to_ssr_uri returns link", re and re:sub(1, 6) == "ssr://")
local n2 = parser.parse_uri(re)
check("ssr roundtrip proto", n2 and n2.proto == "ssr")
check("ssr roundtrip server", n2 and n2.server == "127.0.0.1")
check("ssr roundtrip port", n2 and n2.port == 8388)
check("ssr roundtrip method", n2 and n2.method == "aes-128-cfb")
check("ssr roundtrip password", n2 and n2.password == "test-password")
check("ssr roundtrip protocol", n2 and n2.protocol == "auth_aes128_md5")
check("ssr roundtrip obfs", n2 and n2.obfs == "http_simple")
check("ssr roundtrip obfs_param", n2 and n2.obfs_param == "download.windowsupdate.com")
check("ssr roundtrip name", n2 and n2.name == "测试节点")
check("ssr roundtrip group", n2 and n2.group == "测试组")
-- ---------- Clash.Meta / Mihomo 输出 ----------
local cm = output.generate({ n }, "clashmeta")
check("clashmeta type ssr", cm and cm:find("type: ssr") ~= nil)
check("clashmeta cipher", cm and cm:find("cipher: aes%-128%-cfb") ~= nil)
check("clashmeta password", cm and cm:find("password: test%-password") ~= nil)
check("clashmeta protocol", cm and cm:find("protocol: auth_aes128_md5") ~= nil)
check("clashmeta obfs", cm and cm:find("obfs: http_simple") ~= nil)
check("clashmeta obfs-param", cm and cm:find("obfs%-param: download%.windowsupdate%.com") ~= nil)
-- ---------- Shadowrocket(base64 URI)输出包含 ssr:// ----------
local shr = output.generate({ n }, "shadowrocket")
check("shadowrocket has ssr", shr and util.base64_decode(shr):find("ssr://") ~= nil)
-- ---------- Loon 保留 SSR,Surge 丢弃 ----------
local loon = output.generate({ n }, "loon")
check("loon has ssr", loon and loon:find("ssr, 127%.0%.0%.1, 8388") ~= nil)
check("loon encrypt-method", loon and loon:find("encrypt%-method=aes%-128%-cfb") ~= nil)
check("loon protocol", loon and loon:find("protocol=auth_aes128_md5") ~= nil)
local surge = output.generate({ n }, "surge")
check("surge drops ssr", surge and surge:find("127%.0%.0%.1") == nil)
-- ---------- sing-box / V2Ray 丢弃 SSR ----------
local sb = output.generate({ n }, "singbox")
check("singbox drops ssr", sb and util.json_decode(sb) and #util.json_decode(sb).outbounds == 0)
local v2 = output.generate({ n }, "v2ray")
check("v2ray drops ssr", v2 and util.json_decode(v2) and #util.json_decode(v2).outbounds == 0)
-- ---------- 最小 ssr(无参数,默认 origin/plain) ----------
local minimal = parser.parse_uri("ssr://" .. util.base64_encode("1.2.3.4:80:origin:aes-256-cfb:plain:" .. util.base64_encode("p")))
check("ssr minimal", minimal and minimal.proto == "ssr" and minimal.server == "1.2.3.4" and minimal.password == "p")
-- ---------- base64url 参数解码回退 ----------
local b64u = util.base64_url_encode("香港节点")
check("base64url bare", b64u == "6aaZ5riv6IqC54K5")
check("base64url roundtrip", util.base64_url_decode(b64u) == "香港节点")
print(string.format("\n%d passed, %d failed", passed, failed))
os.exit(failed == 0 and 0 or 1)