mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-05 01:33:56 +08:00
🎉 Sync 2026-09-27 21:03:26
This commit is contained in:
1 parent
f4559bbed1
commit
065763f7fd
673 files changed
+261285
-34160
No files matched your search
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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))
|
||||
@@ -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.
|
||||
@@ -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) 文件。
|
||||
@@ -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
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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 构建环境验证
|
||||
@@ -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
|
||||
@@ -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` 并在目标设备手动验证菜单/功能。
|
||||
@@ -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` 路径解析为准,迁移时需在设备上确认。
|
||||
@@ -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 "注意:对该节点的修改会在订阅下次更新或重新保存后被覆盖。"
|
||||
Symlink
+1
@@ -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
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user