Files

14 KiB
Raw Permalink Blame History

luci-app-substore

简体中文 | English

原生 OpenWrt / ImmortalWrt LuCI 应用,用于管理机场 / 代理订阅:解析订阅节点、 筛选、去重、重命名与分组,再转换为客户端可用的配置格式输出。

参考 Sub-Store 的功能与用户体验, 独立设计与实现:不使用 Docker,不依赖外部云端服务,资源占用友好,适配低配置路由器。

Screenshot

安装

包名中的版本号必须与 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 时自动显示简体中文

使用方法

  1. 添加订阅 —— 粘贴订阅 URL;可选的按订阅 cron 定时、规则或下载代理。 无订阅源时可「添加本地订阅」:粘贴节点文本或表单逐条录入。
  2. 更新 —— 下载、解析并过滤节点。
  3. 浏览节点 —— 筛选(分组 / 协议 / 关键词)、排序、探测延迟;勾选复选框后「删除」 可批量删除,行内可编辑 / 删除 / 改分组,「刷新」重载列表。
  4. 导出 —— 任选 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 单元测试

文档

开发与测试

核心逻辑为纯 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 文件。