14 KiB
luci-app-substore
简体中文 | English
原生 OpenWrt / ImmortalWrt LuCI 应用,用于管理机场 / 代理订阅:解析订阅节点、 筛选、去重、重命名与分组,再转换为客户端可用的配置格式输出。
参考 Sub-Store 的功能与用户体验, 独立设计与实现:不使用 Docker,不依赖外部云端服务,资源占用友好,适配低配置路由器。
安装
包名中的版本号必须与 Makefile 的
PKG_VERSION/PKG_RELEASE保持一致 (当前2.7.3-r3)。
最低支持 OpenWrt / ImmortalWrt 23.05(更早的版本不在支持范围内)。
opkg(OpenWrt / ImmortalWrt 24.10 及更早):
opkg install luci-app-substore_*.ipk
# 中文界面需额外安装翻译包
opkg install luci-i18n-substore-zh-cn_*.ipk
OpenWrt 25.12 及更新版本(apk):
apk add --allow-untrusted luci-app-substore-*.apk
# 中文界面需额外安装翻译包
apk add --allow-untrusted luci-i18n-substore-zh-cn-*.apk
主包不含译文,未装翻译包时界面为英文。翻译包的版本号由
po/目录的提交时间推导 (LuCI 的PKG_PO_VERSION),与主包不同名,按文件名通配安装即可。装好后 LuCI 语言设为「简体中文」即生效(该包的 uci-defaults 会自动把它加进luci.languages)。
然后在 LuCI 菜单打开:服务 → 订阅。
从源码编译
在版本与目标固件匹配的 OpenWrt / ImmortalWrt 源码树中:
cd openwrt
./scripts/feeds update -a
./scripts/feeds install -a
git clone https://github.com/Arthur97172/luci-app-substore.git package/luci-app-substore
make menuconfig # LuCI → Applications → luci-app-substore
make package/luci-app-substore/compile V=s
简体中文翻译包 luci-i18n-substore-zh-cn 由 luci.mk 按 po/zh_Hans/ 自动生成,
与主包同属 package/luci-app-substore 目录,随同一条 compile 命令一并产出。但它是
HIDDEN 包(无 Kconfig prompt),编译前要显式打开语言符号,否则不会被选中:
echo "CONFIG_LUCI_LANG_zh_Hans=y" >> .config
make defconfig
注意不存在
make package/luci-i18n-substore-zh-cn/compile这个目标 —— make 的 目标按目录生成,不按包名(详见 docs/BUILD.md)。
.ipk(或 apk 构建下的 .apk)生成于 bin/packages/.../ 下,按上面的方式安装即可。
功能特性
订阅管理
- 多订阅源的新增 / 编辑 / 删除 / 更新,支持手动更新与按订阅的定时(cron)更新; 状态总览显示节点数、最近更新时间与错误信息
- 勾选批量删除:勾选一条或多条订阅(表头选择框可全选)后点「删除」一次删掉; 一条都没勾选时不会删除任何东西
- 每个订阅的剩余流量 / 剩余时长(解析
subscription-userinfo响应头) - 订阅代理:经
http/https/socks4/socks5/socks5h代理下载订阅, 用于订阅源直连失败时 - 订阅客户端类型:按订阅指定下载时发送的
User-Agent,内置 Clash Verge / v2rayN / Clash Party / FlClash 预设,也可自定义(直接填要原样发送的 UA 请求头, 形如clash-verge/v2.5.0,最长 256 字符、不含控制字符)。部分机场按 UA 下发节点 —— 同一个链接只有用绑定的客户端才解析得出真实节点,用错会得到「与您使用客户端不兼容」 的占位节点(能解析成功,但全是127.0.0.1:1080的假节点) - 组合订阅:把任意子集的现有订阅合并成一个新订阅,拥有独立名称、token 与订阅链接; 源订阅更新或被删除后组合自动重算(删除时死 id 会从「来源」里摘掉, 来源被删光则报错,而不是静默变成 0 节点)
- 本地订阅:不填 URL,直接粘贴节点文本导入(一次一种格式,自动判定), 或用表单按协议动态字段逐条录入节点
输入解析
- 订阅格式:URI 列表、Base64、JSON、Clash YAML、sing-box JSON、V2Ray / Xray JSON、
Surge / Surfboard / Loon / Quantumult X 配置、wg-quick / AmneziaWG
.conf - 节点协议:
vmess/vless/trojan/shadowsocks/ssr/hysteria/hysteria2/tuic/wireguard/socks(http可导入并导出,但不在表单可选协议内) - WireGuard / AmneziaWG:完整字段与
amnezia-wg-option子块, 可直接粘贴 AmneziaWG 客户端导出的.conf内容 - 解析容错:缺
server或端口不在 1–65535 的残缺节点在解析阶段即丢弃 —— 否则会被写成客户端无法加载的配置,一个坏节点废掉整个订阅
节点处理
- 按分组 / 协议筛选、关键词搜索、排序;分组可在表格内直接修改(无刷新保存)
- 单节点编辑 / 删除,勾选后批量删除
- 按订阅规则:关键词包含 / 排除、协议筛选、去重、重命名(精确匹配 / 正则 / 占位符模板)
网络探测(节点页)
- Ping(ICMP)、TCPing(TCP 连接)、URL 测试(HTTP),并行探测并显示成功数与平均延迟
转换与输出
- 15 种输出格式:Plain JSON、Stash、Clash.Meta / Mihomo、Clash 原版、Surfboard、Surge、
Surge Mac、Loon、Egern、Shadowrocket、Quantumult X、sing-box、V2Ray / Xray、
V2Ray URI、WireGuard / AmneziaWG
.conf - SSR(
ssr://)只能原样输出到支持它的客户端(Mihomo、Stash、Loon、Shadowrocket), 其余目标会将其丢弃;VLESS 同理,Surge / Surfboard / Surge Mac 的协议清单里没有它 (Surge 家族与 Loon 的清单见output_formats.lua的FAMILY_CAPS; Egern 的配置是 YAML,清单见output_egern.lua的EGERN_KEY—— 它有 VLESS 与 WireGuard,没有 SSR 与 Hysteria v1) - 只输出目标客户端真正能加载的内容:按目标能力过滤协议;数组字段按客户端要求的类型 输出;策略组成员列表剔除会破坏语法的节点名
- sing-box / V2Ray(Xray) 输出完整可用配置(含分流规则),可直接作为单文件配置启动
订阅链接
- 每个订阅独立随机 token → 公开下载端点
/substore/download?token=<token>&target=<format>,Passwall / OpenClash 等可直接拉取 - 列表页的格式下拉在订阅解析出节点之前(刚添加、更新失败、节点数为 0) 置灰不可选,避免生成必然为空的订阅链接
LuCI 界面与国际化
- 默认英文,运行时语言为
zh-cn时自动显示简体中文
使用方法
- 添加订阅 —— 粘贴订阅 URL;可选的按订阅 cron 定时、规则或下载代理。 无订阅源时可「添加本地订阅」:粘贴节点文本或表单逐条录入。
- 更新 —— 下载、解析并过滤节点。
- 浏览节点 —— 筛选(分组 / 协议 / 关键词)、排序、探测延迟;勾选复选框后「删除」 可批量删除,行内可编辑 / 删除 / 改分组,「刷新」重载列表。
- 导出 —— 任选 15 种格式之一,或复制订阅链接供下游客户端(Passwall / OpenClash / …)使用。
重命名规则的匹配语法是 Lua 模式,不是 PCRE。
|表示「或」,但只在顶层 生效:写成(a|b)不会展开成「a 或 b」,而是按字面匹配(要求名字里真的出现a|b)。多分支直接写a|b,或拆成多条规则。字符类[...]内的|同样是字面。
已知限制
以下均为经代码级审计确认的限制,是有意保留而非疏漏;每项「为何不改」的依据见 docs/LEGACY_ISSUES.md。
- 混合格式文本导入会被拒绝 —— 本地粘贴的节点文本一次只能是一种格式
(URI / Base64 / YAML / JSON / WireGuard
.conf),同一种格式内可混用协议。 混用会明确报错并列出检出的格式,而不是静默丢掉一部分节点。 远程订阅不受此检查影响:订阅内容若混用格式,仍按优先级取一种(与历史行为一致)。 - hysteria v1 导出到 sing-box 需自行补
up/down(带宽) —— 本项目的节点模型 不承载带宽字段,凭空填默认值属于猜测,故不输出。 - hysteria v1 的 URI 只保证「本包导出 → 本包导入」不失真 —— 上游 URI 规范 (文档站点持续 404)未能核实,因此认不出的查询参数一律忽略,不臆造其语义。
http协议不能在界面上手工新建 —— 权威协议表不含它,故表单不提供该选项; 但它可由 Clash / JSON 配置导入,并能正常导出。- AmneziaWG 参数在界面上是一个 JSON 文本框(
amnezia-wg-option)—— 数据往返 正确,但需手写 JSON。 - wget 后端的体积与重定向检查发生在「请求发出之后」 —— busybox wget 没有
--max-filesize/--max-redirect的等价选项。超限响应体会被丢弃并报错 (不会进入解析流程),重定向链也会逐跳复检、任一跳指向内网即整体拒绝; 差别仅在于这两项检查晚于请求发出。安装 curl 可完全避免(curl 路径用--max-filesize与--max-redirs 0在发出前拦截)。 - 本机完全没有 DNS 解析能力时,wget 后端会拒绝下载域名订阅 —— 这类设备上预检
无法校验目标地址,而 wget 又没有 curl 的
%{remote_ip}可用于「连接后复核」, 按「校验不了就拒绝」处理(错误信息会提示安装 curl)。字面 IP 目标与有解析手段的 设备不受影响。
目录结构
.
├── Makefile # OpenWrt 包定义
├── LICENSE # GPL-3.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
│ │ ├── rpcd/acl.d/luci-app-substore.json # ACL 组(菜单 depends.acl 引用)
│ │ └── substore/*.lua # 核心逻辑(不依赖 luci.*)
├── po/zh_Hans/substore.po # 简体中文翻译(编译为独立包 luci-i18n-substore-zh-cn)
├── docs/ # 设计与指南
└── tests/ # 自包含 Lua 5.1 单元测试
文档
- docs/ARCHITECTURE.md — 架构设计
- docs/PLAN.md — 分阶段开发计划
- docs/BUILD.md — 从 OpenWrt SDK / 源码树构建
- docs/INSTALL.md — 安装
- docs/SECURITY.md — 安全模型
- docs/TESTING.md — 测试
- docs/UCODE_MIGRATION.md —
.htm→.ut(ucode)迁移说明 - CHANGELOG.md — 更新日志
- docs/LEGACY_ISSUES.md — 遗留缺陷汇总(待决定修复方案)
开发与测试
核心逻辑为纯 Lua 5.1,不依赖 luci.*,无需设备即可单元测试。tests/ 下每个测试文件自包含:
lua5.1 tests/run_tests.lua # 或任意单个测试文件
for f in tests/*.lua; do lua5.1 "$f" || exit 1; done
LuCI 界面与 cron 行为仍需在目标设备上验证 —— 见 docs/TESTING.md。
安全
SSRF 防护(拒绝内网 / 保留 / 链路本地地址;有解析手段却解析不出即拒绝,不给
「解析不出来就放行」留绕过口;重定向链逐跳复检)、协议白名单与端口范围校验
(1–65535)、响应体大小与超时限制、下载临时文件在每条退出路径上清理(/tmp 是
tmpfs)、命令注入防护(白名单解析 + shell 引用 + 探测目标拒绝以 - 开头的主机名)、
公开下载端点基于 token 的访问控制、日志不含凭据。
无 DNS 解析能力的设备(nixio 与 nslookup 均不可用)无法在下载前校验域名,
此时 curl 后端改用 %{remote_ip} 在连接建立后复核实际对端地址(同时关闭
DNS rebinding 的 TOCTOU 窗口);wget 后端没有等价手段,直接拒绝而非放行
(见「已知限制」)。
表单提交校验:写操作要求 token 存在、非空,并在可取到时与 LuCI 的
context.authtoken 比对(这正是框架模板里 token 的取值来源);校验失败会回显
原因,不会静默丢弃。访问控制:luci-app-substore ACL 组经 menu.d 的
depends.acl 生效 —— 未获授权的 LuCI 用户看不到本应用入口,直接访问 URL 也会
被拒(403)(依据:ucode dispatcher 在分发时校验路径上累积的 depends.acl,
已从上游源码核实,并已在设备上实测通过)。授权方式见 docs/SECURITY.md。
数据落盘权限:/etc/substore 目录 0700,subscriptions.json 与 nodes/*.json
0600(前者含订阅 URL 与公开下载 token,后者含 uuid / 密码 / 私钥)——
io.open 按 umask 创建(通常 0644),同机任何用户都能读到,因此写入后显式收紧。
批量节点探测的并发上限为 16 个进程(probe.MAX_PARALLEL):节点数由订阅内容决定,
不限并发会把路由器的 fd / 进程额度打满,之后 io.popen 静默失败。
详见 docs/SECURITY.md。
许可证
GPL-3.0-or-later —— 见 LICENSE 文件。
