🍓 Sync 2026-09-29 07:32:36

This commit is contained in:
github-actions[bot] committed 2026-09-29 07:32:36 +08:00
1 parent 08d3ec6fdd
commit 085188d2d1
13 files changed
+488 -192

No files matched your search

+36
View File
@@ -2,6 +2,42 @@
All notable changes to this project will be documented in this file.
## [2.4.0-r1] - sing-box / V2Ray 输出完整配置
- ⚠️ **破坏性变更**:`target=singbox` 与 `target=v2ray` 由「仅含 `outbounds` 的片段」
改为「`outbounds` + 分流的完整可用配置」。2.3.x 的片段需粘进已有配置使用;
2.4.0 起可直接作为单文件配置启动。已粘进客户端的老链接会拿到不同结构,请重新导出。
- sing-box 完整配置(`output_singbox.lua`)
- 节点出站 + `selector`(tag `select`,供手工切换)+ `urltest`(tag `auto`,自动测速)
+ `direct` / `block`
- `route.final` 指向 `select`;内置 `ip_is_private` → `direct` 私网直连规则;
`auto_detect_interface` 开启
- **刻意省略 route 规则的 `action` 字段**:该字段自 sing-box 1.11.0 起才存在,其默认值
即 `"route"`。sing-box 会拒绝未知字段,而省略默认值在 1.10 与 1.11+ 上都能工作
- 无可用节点时不生成 `selector` / `urltest`(其 `outbounds` 不允许为空),`final` 退回 `direct`
- V2Ray / Xray 完整配置(`output_v2ray.lua`)
- 节点出站 + `freedom`(tag `direct`) / `blackhole`(tag `block`) + `log`
- `observatory`(`subjectSelector` / `probeUrl` / `probeInterval`)+ `routing.balancers`
(tag `auto`,`leastPing`)——`leastPing` 必须依赖 observatory 的探测结果才会生效
- `routing.rules`:`geoip:private` → `direct`,其后 `tcp,udp` 兜底 → `balancerTag: auto`;
`domainStrategy` 为 `IPIfNonMatch`
- 无可用节点时改为 `outboundTag: direct` 兜底,且不生成 `balancers` / `observatory`
- 两者均**不含 `inbounds` / `dns`**:会绑定本地监听端口、覆盖用户既有 DNS 设置,交由用户维护
- 修复 `util.json_encode` 无法表达空对象:Lua 空表经 `is_array` 判定会被编码成 `[]`,
而 sing-box 的 `tls`、Xray 的 `settings` 必须是对象。新增 `util.JSON_EMPTY_OBJECT` 占位符
- 由此修复一处既有缺陷:sing-box 节点 `security=tls` 但无 `sni` / `alpn` 时会产出非法的 `"tls":[]`
- 新增 `util.unique_tags(nodes, reserved)`:sing-box 与 Xray 均要求 outbound tag 唯一,
节点重名(订阅里很常见)或与保留 tag(`direct` / `block` / `select` / `auto`)同名时
自动追加 ` #2`、` #3`
- Xray 的 balancer / observatory `selector` 按**前缀**匹配 tag,因此额外把保留 tag 的
所有真前缀也登记为冲突——否则名为 `d` 的节点会让 `direct` 出站被误纳入负载均衡
- 导出结果可重新导入:完整配置里的 `selector` / `urltest` / `direct` / `block` /
`freedom` / `blackhole` 会被解析器正确跳过,只取真实节点(已加往返测试)
- 新增测试 `tests/output_full_config_test.lua`(82 项)
- `tests/output_formats_test.lua` / `tests/ssr_test.lua` 更新为断言「不含 ssr 出站」
而非「outbounds 为空」,适配完整配置语义
- 版本号 2.3.0-r2 → 2.4.0-r1;README.md / README.en.md / docs/INSTALL.md 同步
## [2.3.0-r2] - 新增 Clash 原版与 WireGuard .conf 输出,输出层清理
- 新增输出格式 **WireGuard / AmneziaWG `.conf`**(`target=wgconf`,别名 `wg` / `wireguard` / `amneziawg` / `amnezia` / `conf`)
+2 -2
View File
@@ -9,8 +9,8 @@ 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.3.0
PKG_RELEASE:=6
PKG_VERSION:=2.4.0
PKG_RELEASE:=7
LUCI_DEPENDS:=+luci-lua-runtime +luci-compat
+17 -3
View File
@@ -67,6 +67,20 @@ group them, then re-emit them in a format your client can consume.
original does not support (vless / hysteria2 / hysteria / tuic / wireguard) are filtered out
- **WireGuard / AmneziaWG `.conf`**: wg-quick single-interface config with `[Interface]` /
`[Peer]` sections and AmneziaWG obfuscation parameters, importable by AmneziaWG clients
- **sing-box / V2Ray (Xray)**: emits a **complete, runnable config** (`outbounds` plus
routing), not just an `outbounds` fragment
- sing-box: node outbounds + `selector` (manual switch) + `urltest` (auto latency test) +
`direct` / `block`; `route.final` points at the selector, with a built-in private-IP
direct rule
- V2Ray/Xray: node outbounds + `freedom` (direct) / `blackhole` (block) + `observatory` +
`routing.balancers` (`leastPing` auto-selection), with built-in `geoip:private` direct
and a catch-all route
- Deliberately **excludes `inbounds` / `dns`**: those bind local listening ports and
override your existing DNS settings — keep them in your own config and merge this
output into it
- ⚠️ Not compatible with 2.3.x: 2.3.x emitted an `outbounds`-only fragment meant to be
pasted into an existing config; from 2.4.0 it is a complete config you can start
directly as a single file
**Subscription links**
- Per-subscription random token → public download endpoint
@@ -79,18 +93,18 @@ group them, then re-emit them in a format your client can consume.
## Installation
> The version in the package name must match `PKG_VERSION` / `PKG_RELEASE` in the
> [Makefile](Makefile) (currently `2.3.0-r2`).
> [Makefile](Makefile) (currently `2.4.0-r1`).
opkg (OpenWrt / ImmortalWrt 24.10 and earlier):
```bash
opkg install luci-app-substore-2.3.0-r2.ipk
opkg install luci-app-substore-2.4.0-r1.ipk
```
apk (OpenWrt / ImmortalWrt 25.12+):
```bash
apk add --allow-untrusted luci-app-substore-2.3.0-r2.apk
apk add --allow-untrusted luci-app-substore-2.4.0-r1.apk
```
Then open LuCI: **Services → Subscriptions**.
+13 -3
View File
@@ -59,6 +59,16 @@
协议(vless / hysteria2 / hysteria / tuic / wireguard)
- **WireGuard / AmneziaWG `.conf`**:wg-quick 单接口配置,含 `[Interface]` / `[Peer]` 与
AmneziaWG 混淆参数,可直接导入 AmneziaWG 客户端
- **sing-box / V2Ray(Xray)**:输出**完整可用配置**(`outbounds` + 分流),而非仅有
`outbounds` 的片段
- sing-box:节点出站 + `selector`(手工切换)+ `urltest`(自动测速)+ `direct` / `block`,
`route.final` 指向 `selector`,内置私网直连规则
- V2Ray/Xray:节点出站 + `freedom`(direct) / `blackhole`(block) + `observatory` +
`routing.balancers`(`leastPing` 自动选优),内置 `geoip:private` 直连与兜底分流
- 刻意**不含 `inbounds` / `dns`**:这两项会绑定本地监听端口、覆盖你既有的 DNS 设置,
请在你自己的配置里维护;把本输出合并进已有配置即可
- ⚠️ 与 2.3.x 不兼容:2.3.x 输出的是仅含 `outbounds` 的片段,需要粘进已有配置使用;
2.4.0 起是完整配置,可直接作为单文件配置启动
**订阅链接**
- 每个订阅独立随机 token → 公开下载端点
@@ -70,18 +80,18 @@
## 安装
> 包名中的版本号必须与 [Makefile](Makefile) 的 `PKG_VERSION` / `PKG_RELEASE` 保持一致
> (当前 `2.3.0-r2`)。
> (当前 `2.4.0-r1`)。
opkg(OpenWrt / ImmortalWrt 24.10 及更早):
```bash
opkg install luci-app-substore-2.3.0-r2.ipk
opkg install luci-app-substore-2.4.0-r1.ipk
```
apk(OpenWrt / ImmortalWrt 25.12+):
```bash
apk add --allow-untrusted luci-app-substore-2.3.0-r2.apk
apk add --allow-untrusted luci-app-substore-2.4.0-r1.apk
```
然后在 LuCI 菜单打开:**服务 → 订阅**。
+3 -3
View File
@@ -3,18 +3,18 @@
> 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.3.0-r2)。
> 包名中的版本号需与 Makefile 的 `PKG_VERSION` / `PKG_RELEASE` 保持同步(当前 2.4.0-r1)。
## Install (opkg — OpenWrt / ImmortalWrt 24.10 及更早)
```bash
opkg install luci-app-substore-2.3.0-r2.ipk
opkg install luci-app-substore-2.4.0-r1.ipk
```
## Install (apk — OpenWrt / ImmortalWrt 25.12+)
```bash
apk add --allow-untrusted luci-app-substore-2.3.0-r2.apk
apk add --allow-untrusted luci-app-substore-2.4.0-r1.apk
```
Refresh LuCI:
@@ -27,6 +27,11 @@ local TYPE_MAP = {
http = "http",
}
-- 该协议能否落到 sing-box outbound(SSR 无法表达,跳过)
local function supported(proto)
return (TYPE_MAP[proto] or proto) ~= "ssr"
end
-- 构建 TLS 字段
local function build_tls(n)
local tls
@@ -47,6 +52,9 @@ local function build_tls(n)
elseif n.skip_cert_verify ~= nil then
tls.insecure = bool(n.skip_cert_verify)
end
-- 空表会被 json_encode 编码成 [],而 tls 必须是对象;
-- security=tls 但无 sni/alpn/insecure 时就会走到这里
if next(tls) == nil then return util.JSON_EMPTY_OBJECT end
return tls
end
return nil
@@ -79,12 +87,13 @@ local function build_transport(n)
end
-- 单节点 → sing-box outbound 表
function M.to_outbound(n)
-- tag 可选:完整配置里由 util.unique_tags 统一分配,避免节点重名导致 tag 冲突
function M.to_outbound(n, tag)
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 "")),
tag = tag or n.name or ((n.server or "") .. ":" .. tostring(n.port or "")),
server = n.server or "",
server_port = tonumber(n.port) or 0,
}
@@ -145,15 +154,53 @@ function M.to_outbound(n)
return o
end
function M.generate(nodes)
local outbounds = {}
-- 保留 tag:节点名不得与之重名(util.unique_tags 负责消解)
local RESERVED_TAGS = { select = true, auto = true, direct = true, block = true }
-- 生成完整 sing-box 配置(outbounds + route)。
-- 不含 inbounds / dns:这两项会绑定本地监听端口、覆盖用户既有 DNS 设置,
-- 由用户在自己的配置里维护,本输出只负责节点与分流。
--
-- 版本兼容说明:route 规则里的 action 字段自 1.11.0 起才存在,其默认值即 "route",
-- 因此这里刻意省略 action —— 未知字段会被 sing-box 拒绝,而省略默认值在
-- 1.10 与 1.11+ 上都能工作。
function M.generate(nodes, options)
local usable = {}
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
if type(n) == "table" and supported(n.proto) then usable[#usable + 1] = n end
end
return util.json_encode({ outbounds = outbounds })
local tags = util.unique_tags(usable, RESERVED_TAGS)
local outbounds, proxy_tags = {}, {}
for i, n in ipairs(usable) do
outbounds[#outbounds + 1] = M.to_outbound(n, tags[i])
proxy_tags[#proxy_tags + 1] = tags[i]
end
local final
if #proxy_tags > 0 then
-- select 供手工切换,auto 自动测速选优
local sel = { type = "selector", tag = "select", outbounds = { "auto", "direct" } }
for _, t in ipairs(proxy_tags) do sel.outbounds[#sel.outbounds + 1] = t end
sel.default = "auto"
outbounds[#outbounds + 1] = sel
outbounds[#outbounds + 1] = { type = "urltest", tag = "auto", outbounds = proxy_tags }
final = "select"
else
-- 无可用节点时不能生成 selector / urltest(outbounds 不允许为空)
final = "direct"
end
outbounds[#outbounds + 1] = { type = "direct", tag = "direct" }
outbounds[#outbounds + 1] = { type = "block", tag = "block" }
return util.json_encode({
outbounds = outbounds,
route = {
rules = { { ip_is_private = true, outbound = "direct" } },
final = final,
auto_detect_interface = true,
},
})
end
return M
@@ -47,13 +47,19 @@ local function build_stream_settings(n)
return ss
end
-- 该协议能否落到 V2Ray / Xray outbound(SSR 无法表达,跳过)
local function supported(proto)
return (proto or "vmess") ~= "ssr"
end
-- 单节点 → V2Ray outbound 表
function M.to_outbound(n)
-- tag 可选:完整配置里由 util.unique_tags 统一分配,避免节点重名导致 tag 冲突
function M.to_outbound(n, tag)
local proto = n.proto or "vmess"
if proto == "ssr" then return nil end -- V2Ray / Xray 不支持 SSR,跳过
if not supported(proto) then return nil end
local o = {
protocol = (proto == "ss" and "shadowsocks" or proto),
tag = n.name or ((n.server or "") .. ":" .. tostring(n.port or "")),
tag = tag or n.name or ((n.server or "") .. ":" .. tostring(n.port or "")),
settings = {},
streamSettings = build_stream_settings(n),
}
@@ -94,15 +100,61 @@ function M.to_outbound(n)
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
-- Xray 的 balancer / observatory selector 按「前缀」匹配 outbound tag。
-- 若某节点 tag 恰好是 "direct" / "block" / "auto" 的前缀(例如节点名就叫 "d"、"auto"),
-- selector 会把这些保留出站一并纳入负载均衡,导致流量被丢进 direct / block。
-- 因此把保留 tag 的所有真前缀也登记为冲突,交给 util.unique_tags 加后缀消解。
local function reserved_with_prefixes(names)
local r = {}
for _, s in ipairs(names) do
r[s] = true
for i = 1, #s - 1 do r[s:sub(1, i)] = true end
end
return util.json_encode({ outbounds = outbounds })
return r
end
-- 生成完整 V2Ray / Xray 配置(outbounds + observatory + routing)。
-- 不含 inbounds / dns:这两项会绑定本地监听端口、覆盖用户既有 DNS 设置,
-- 由用户在自己的配置里维护,本输出只负责节点与分流。
function M.generate(nodes, options)
local usable = {}
for _, n in ipairs(nodes or {}) do
if type(n) == "table" and supported(n.proto) then usable[#usable + 1] = n end
end
local tags = util.unique_tags(usable, reserved_with_prefixes({ "direct", "block", "auto" }))
local outbounds, proxy_tags = {}, {}
for i, n in ipairs(usable) do
outbounds[#outbounds + 1] = M.to_outbound(n, tags[i])
proxy_tags[#proxy_tags + 1] = tags[i]
end
-- direct / block 必须存在:路由规则要按 tag 引用它们
outbounds[#outbounds + 1] = { protocol = "freedom", tag = "direct", settings = util.JSON_EMPTY_OBJECT }
outbounds[#outbounds + 1] = { protocol = "blackhole", tag = "block", settings = { response = { type = "none" } } }
local routing = {
domainStrategy = "IPIfNonMatch",
rules = { { type = "field", ip = { "geoip:private" }, outboundTag = "direct" } },
}
local cfg = { log = { loglevel = "warning" }, outbounds = outbounds, routing = routing }
if #proxy_tags > 0 then
-- leastPing 依赖 observatory 的探测结果;不配 observatory 时该策略不生效
cfg.observatory = {
subjectSelector = proxy_tags,
probeUrl = "https://www.gstatic.com/generate_204",
probeInterval = "10s",
enableConcurrency = true,
}
routing.balancers = { { tag = "auto", selector = proxy_tags, strategy = { type = "leastPing" } } }
routing.rules[#routing.rules + 1] = { type = "field", network = "tcp,udp", balancerTag = "auto" }
else
-- 无可用节点时没有 balancer 可引用;首元素 direct 即默认出站
routing.rules[#routing.rules + 1] = { type = "field", network = "tcp,udp", outboundTag = "direct" }
end
return util.json_encode(cfg)
end
return M
@@ -147,6 +147,11 @@ end
-- JSON null 占位符:仅在数组中用于保留位置(对象中的 null 仍解码为 nil)
local JSON_NULL = {}
-- JSON 空对象占位符。Lua 的空表无法区分 {} 与 [](is_array 判定空表为数组),
-- 而 sing-box / Xray 的部分字段必须是对象("tls": {}、"settings": {}),
-- 编码成 [] 会被客户端拒绝。需要空对象时显式使用本常量。
M.JSON_EMPTY_OBJECT = setmetatable({}, { __tostring = function() return "{}" end })
function M.json_encode(v)
local function enc(v)
local t = type(v)
@@ -169,7 +174,9 @@ function M.json_encode(v)
elseif t == "boolean" then
return v and "true" or "false"
elseif t == "table" then
if is_array(v) then
if v == M.JSON_EMPTY_OBJECT then
return "{}"
elseif is_array(v) then
local a = {}
for i = 1, #v do a[#a + 1] = enc(v[i]) end
return "[" .. table.concat(a, ",") .. "]"
@@ -378,4 +385,28 @@ function M.human_duration(secs)
return "不足1分钟"
end
-- 为一组节点生成唯一 tag(供 sing-box / Xray 完整配置使用)。
-- 节点名可能重复,也可能与保留 tag(direct / block / select / auto …)同名;
-- sing-box 与 Xray 都要求 outbound tag 唯一,重名时追加 " #2"、" #3"。
-- 返回与 nodes 等长的 tag 数组。
function M.unique_tags(nodes, reserved)
local used = {}
for k in pairs(reserved or {}) do used[k] = true end
local tags = {}
for i, n in ipairs(nodes or {}) do
local base = n.name
if base == nil or base == "" then
base = (n.server or "") .. ":" .. tostring(n.port or "")
end
local tag, k = base, 2
while used[tag] do
tag = base .. " #" .. k
k = k + 1
end
used[tag] = true
tags[i] = tag
end
return tags
end
return M
@@ -57,15 +57,18 @@ 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)
-- sing-box / v2ray 自 2.4.0 起输出完整配置:outbounds 除节点外还含
-- selector / urltest(sing-box)与 direct / block,故此处不再断言节点数量,
-- 改为断言节点出站按序在前、且结构与分流齐全(详见 output_full_config_test.lua)
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 has outbounds", sb and sb.outbounds ~= nil and #sb.outbounds == 4 + 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 has outbounds", vr and vr.outbounds ~= nil and #vr.outbounds == 4 + 2)
check("v2ray protocol vmess", vr and vr.outbounds[1] and vr.outbounds[1].protocol == "vmess")
local uri = output.generate(nodes, "v2rayuri")
@@ -0,0 +1,220 @@
-- output_full_config_test.lua — sing-box / V2Ray(Xray) 完整配置输出
-- 用法:lua5.1 tests/output_full_config_test.lua
--
-- 覆盖 2.4.0 起的行为:target=singbox / target=v2ray 由「仅 outbounds 片段」
-- 改为「outbounds + 分流」的完整配置。
package.path = "./root/usr/share/?.lua;" .. package.path
local util = require("substore.util")
local output = require("substore.output")
local singbox = require("substore.output_singbox")
local v2ray = require("substore.output_v2ray")
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 = "HK 01", server = "1.1.1.1", port = 443, uuid = "u1", net = "tcp" },
{ proto = "vless", name = "HK 01", server = "2.2.2.2", port = 443, uuid = "u2" },
{ proto = "trojan", name = "JP", server = "3.3.3.3", port = 443, password = "p" },
{ proto = "ssr", name = "SSR", server = "4.4.4.4", port = 8388, method = "aes-128-cfb", password = "p" },
}
-- ---------- util.JSON_EMPTY_OBJECT:空表必须能表达成对象 ----------
-- Lua 空表无法区分 {} 与 [],is_array({}) 为 true,默认会编码成 [],
-- 而 sing-box 的 tls、Xray 的 settings 都必须是对象
check("empty table encodes as array (baseline)", util.json_encode({}) == "[]")
check("JSON_EMPTY_OBJECT encodes as object", util.json_encode(util.JSON_EMPTY_OBJECT) == "{}")
check("JSON_EMPTY_OBJECT nested", util.json_encode({ settings = util.JSON_EMPTY_OBJECT }) == '{"settings":{}}')
-- ---------- util.unique_tags ----------
local ut = util.unique_tags(
{ { name = "A" }, { name = "A" }, { name = "B" }, { name = "direct" } },
{ direct = true }
)
check("unique_tags dedupes", ut[1] == "A" and ut[2] == "A #2" and ut[3] == "B")
check("unique_tags avoids reserved", ut[4] == "direct #2")
check("unique_tags falls back to server:port", util.unique_tags({ { server = "h", port = 1 } })[1] == "h:1")
-- ============================================================
-- sing-box 完整配置
-- ============================================================
local sb_raw = singbox.generate(nodes)
local sb = util.json_decode(sb_raw)
check("sb is valid json", sb ~= nil)
check("sb has outbounds", type(sb.outbounds) == "table")
check("sb has route", type(sb.route) == "table")
-- 不含 inbounds / dns(会绑定本地端口、覆盖用户 DNS)
check("sb has no inbounds", sb.inbounds == nil)
check("sb has no dns", sb.dns == nil)
-- 节点出站按序在前;SSR 被丢弃
check("sb node1 is vmess", sb.outbounds[1].type == "vmess" and sb.outbounds[1].tag == "HK 01")
check("sb node2 tag deduped", sb.outbounds[2].type == "vless" and sb.outbounds[2].tag == "HK 01 #2")
check("sb node3 is trojan", sb.outbounds[3].type == "trojan" and sb.outbounds[3].tag == "JP")
check("sb drops ssr", sb_raw:find('"ssr"') == nil)
check("sb outbound count", #sb.outbounds == 3 + 4) -- 节点 + selector + urltest + direct + block
-- selector
local sel, urltest, direct, block
for _, o in ipairs(sb.outbounds) do
if o.type == "selector" then sel = o
elseif o.type == "urltest" then urltest = o
elseif o.type == "direct" then direct = o
elseif o.type == "block" then block = o end
end
check("sb has selector", sel ~= nil and sel.tag == "select")
check("sb selector default auto", sel and sel.default == "auto")
check("sb selector lists auto+direct first", sel and sel.outbounds[1] == "auto" and sel.outbounds[2] == "direct")
check("sb selector lists all proxies", sel and #sel.outbounds == 2 + 3)
check("sb has urltest", urltest ~= nil and urltest.tag == "auto")
check("sb urltest lists all proxies", urltest and #urltest.outbounds == 3)
check("sb urltest outbound1", urltest and urltest.outbounds[1] == "HK 01")
check("sb urltest outbound2", urltest and urltest.outbounds[2] == "HK 01 #2")
check("sb has direct", direct ~= nil and direct.tag == "direct")
check("sb has block", block ~= nil and block.tag == "block")
-- route
check("sb route.final is select", sb.route.final == "select")
check("sb route auto_detect_interface", sb.route.auto_detect_interface == true)
check("sb route rule count", #sb.route.rules == 1)
check("sb route private -> direct", sb.route.rules[1].ip_is_private == true and sb.route.rules[1].outbound == "direct")
-- action 自 1.11.0 起才存在且默认即 "route";省略它才能同时兼容 1.10 与 1.11+
check("sb route rule omits action (version compat)", sb.route.rules[1].action == nil)
-- tls 必须是对象:security=tls 但无 sni 时曾产出非法的 "tls":[]
local tls_node = { { proto = "vmess", name = "T", server = "1.1.1.1", port = 443, uuid = "u", security = "tls" } }
local tls_raw = singbox.generate(tls_node)
check("sb tls is object not array", tls_raw:find('"tls":{}') ~= nil and tls_raw:find('"tls":%[%]') == nil)
check("sb tls with sni keeps server_name",
singbox.generate({ { proto = "vmess", name = "T", server = "1.1.1.1", port = 443, uuid = "u",
security = "tls", sni = "example.com" } }):find('"server_name":"example.com"') ~= nil)
-- 节点名与保留 tag 重名时不得冲突
local clash_name = singbox.generate({ { proto = "vmess", name = "direct", server = "1.1.1.1", port = 443, uuid = "u" } })
local cn = util.json_decode(clash_name)
local cn_tags = {}
for _, o in ipairs(cn.outbounds) do cn_tags[o.tag] = (cn_tags[o.tag] or 0) + 1 end
local all_unique = true
for _, c in pairs(cn_tags) do if c > 1 then all_unique = false end end
check("sb tags unique despite reserved name", all_unique)
check("sb renamed reserved node", cn.outbounds[1].tag == "direct #2")
-- 无可用节点:不生成 selector / urltest(outbounds 不允许为空),final 退回 direct
local sb_empty = util.json_decode(singbox.generate({}))
check("sb empty has 2 outbounds", #sb_empty.outbounds == 2)
check("sb empty final direct", sb_empty.route.final == "direct")
local sb_empty_types = {}
for _, o in ipairs(sb_empty.outbounds) do sb_empty_types[o.type] = true end
check("sb empty no selector/urltest", not sb_empty_types.selector and not sb_empty_types.urltest)
check("sb empty keeps direct/block", sb_empty_types.direct and sb_empty_types.block)
-- 确定性:同样输入两次结果一致
check("sb deterministic", singbox.generate(nodes) == singbox.generate(nodes))
-- ============================================================
-- V2Ray / Xray 完整配置
-- ============================================================
local v2_raw = v2ray.generate(nodes)
local v2 = util.json_decode(v2_raw)
check("v2 is valid json", v2 ~= nil)
check("v2 has outbounds", type(v2.outbounds) == "table")
check("v2 has routing", type(v2.routing) == "table")
check("v2 has log", type(v2.log) == "table" and v2.log.loglevel == "warning")
-- 不含 inbounds / dns
check("v2 has no inbounds", v2.inbounds == nil)
check("v2 has no dns", v2.dns == nil)
-- 节点出站按序在前
check("v2 node1 is vmess", v2.outbounds[1].protocol == "vmess" and v2.outbounds[1].tag == "HK 01")
check("v2 node2 tag deduped", v2.outbounds[2].protocol == "vless" and v2.outbounds[2].tag == "HK 01 #2")
check("v2 node3 is trojan", v2.outbounds[3].protocol == "trojan" and v2.outbounds[3].tag == "JP")
check("v2 drops ssr", v2_raw:find('"ssr"') == nil)
check("v2 outbound count", #v2.outbounds == 3 + 2) -- 节点 + freedom + blackhole
-- freedom / blackhole:settings 必须是对象,不能是 []
local freedom, blackhole
for _, o in ipairs(v2.outbounds) do
if o.protocol == "freedom" then freedom = o
elseif o.protocol == "blackhole" then blackhole = o end
end
check("v2 has freedom direct", freedom ~= nil and freedom.tag == "direct")
check("v2 has blackhole block", blackhole ~= nil and blackhole.tag == "block")
check("v2 settings is object not array", v2_raw:find('"settings":%[%]') == nil)
check("v2 blackhole response", blackhole and blackhole.settings.response.type == "none")
-- observatory:leastPing 必须依赖它才生效
check("v2 has observatory", type(v2.observatory) == "table")
check("v2 observatory subjectSelector", v2.observatory and #v2.observatory.subjectSelector == 3)
check("v2 observatory probeUrl", v2.observatory and v2.observatory.probeUrl ~= nil)
check("v2 observatory probeInterval", v2.observatory and v2.observatory.probeInterval == "10s")
-- routing
check("v2 domainStrategy", v2.routing.domainStrategy == "IPIfNonMatch")
check("v2 has balancer", type(v2.routing.balancers) == "table" and #v2.routing.balancers == 1)
local bal = v2.routing.balancers[1]
check("v2 balancer tag auto", bal.tag == "auto")
check("v2 balancer strategy leastPing", bal.strategy.type == "leastPing")
check("v2 balancer selector lists proxies", #bal.selector == 3)
check("v2 rule count", #v2.routing.rules == 2)
check("v2 rule1 private -> direct",
v2.routing.rules[1].type == "field" and v2.routing.rules[1].ip[1] == "geoip:private"
and v2.routing.rules[1].outboundTag == "direct")
check("v2 rule2 catchall -> balancer",
v2.routing.rules[2].network == "tcp,udp" and v2.routing.rules[2].balancerTag == "auto")
-- 前缀匹配防护:Xray 的 selector 按前缀匹配,节点名 "d" 是 "direct" 的前缀,
-- 若不消解会把 direct 出站也纳入负载均衡
local pfx = util.json_decode(v2ray.generate({ { proto = "vmess", name = "d", server = "1.1.1.1", port = 443, uuid = "u" } }))
check("v2 renames prefix-of-reserved node", pfx.outbounds[1].tag == "d #2")
check("v2 balancer selector excludes direct", pfx.routing.balancers[1].selector[1] == "d #2")
-- 无可用节点:没有 balancer 可引用,兜底规则改指 direct
local v2_empty = util.json_decode(v2ray.generate({}))
check("v2 empty has 2 outbounds", #v2_empty.outbounds == 2)
check("v2 empty no balancer", v2_empty.routing.balancers == nil)
check("v2 empty no observatory", v2_empty.observatory == nil)
check("v2 empty rule count", #v2_empty.routing.rules == 2)
check("v2 empty catchall -> direct", v2_empty.routing.rules[2].outboundTag == "direct")
check("v2 deterministic", v2ray.generate(nodes) == v2ray.generate(nodes))
-- ---------- 往返:完整配置重新导入必须只取真实节点 ----------
-- 完整配置里多了 selector / urltest / direct / block(sing-box)与
-- freedom / blackhole(Xray),解析器必须跳过它们而非当成节点
local parser = require("substore.parser")
for _, fmt in ipairs({ "singbox", "v2ray" }) do
local txt = (fmt == "singbox") and sb_raw or v2_raw
local res = parser.parse(txt)
check("roundtrip " .. fmt .. " parses", res ~= nil and #res.nodes == 3)
local protos = {}
for _, n in ipairs(res and res.nodes or {}) do protos[n.proto] = true end
check("roundtrip " .. fmt .. " only real proxies",
protos.vmess and protos.vless and protos.trojan
and not protos.direct and not protos.selector and not protos.urltest and not protos.freedom)
end
-- ---------- 经 output.generate 分发后行为一致 ----------
check("dispatch singbox full", output.generate(nodes, "singbox") == sb_raw)
check("dispatch v2ray full", output.generate(nodes, "v2ray") == v2_raw)
check("dispatch singbox alias", output.generate(nodes, "sing-box") == sb_raw)
-- ---------- 结果 ----------
print(string.format("\n%d passed, %d failed", passed, failed))
os.exit(failed == 0 and 0 or 1)
+13 -2
View File
@@ -81,10 +81,21 @@ local surge = output.generate({ n }, "surge")
check("surge drops ssr", surge and surge:find("127%.0%.0%.1") == nil)
-- ---------- sing-box / V2Ray 丢弃 SSR ----------
-- 完整配置里 outbounds 恒含 direct / block,因此断言「不含 ssr 出站」
-- 而非「outbounds 为空」;无可用节点时也不生成分组
local sb = output.generate({ n }, "singbox")
check("singbox drops ssr", sb and util.json_decode(sb) and #util.json_decode(sb).outbounds == 0)
local sbd = util.json_decode(sb)
local sb_types = {}
for _, o in ipairs(sbd and sbd.outbounds or {}) do sb_types[o.type] = true end
check("singbox drops ssr", sbd and not sb_types.ssr and not sb_types.shadowsocksr)
check("singbox no group without nodes", sbd and not sb_types.selector and not sb_types.urltest)
check("singbox keeps direct/block", sb_types.direct and sb_types.block)
local v2 = output.generate({ n }, "v2ray")
check("v2ray drops ssr", v2 and util.json_decode(v2) and #util.json_decode(v2).outbounds == 0)
local v2d = util.json_decode(v2)
local v2_protos = {}
for _, o in ipairs(v2d and v2d.outbounds or {}) do v2_protos[o.protocol] = true end
check("v2ray drops ssr", v2d and not v2_protos.ssr and not v2_protos.shadowsocksr)
check("v2ray keeps freedom/blackhole", v2_protos.freedom and v2_protos.blackhole)
-- ---------- 最小 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")))
+21 -95
View File
@@ -7,115 +7,41 @@ include $(TOPDIR)/rules.mk
LUCI_TITLE:=LuCI for Tailscale
LUCI_DEPENDS:=+tailscale +jshn +curl +jq +flock
LUCI_PKGARCH:=all
PKG_VERSION:=1.2.17
PKG_RELEASE:=4
PKG_RELEASE:=5
# Keep UCI configs as ordinary package data. The pre-install hook snapshots an
# existing configuration before extraction; the UCI-defaults script restores it
# afterwards. Declaring the same files as opkg conffiles conflicts with this
# lifecycle and creates inactive *-opkg files during upgrades.
define Package/luci-app-tailscale/preinst
#!/bin/sh
state_dir=/etc/.luci-app-tailscale-upgrade
state_pending="$${state_dir}.pending"
legacy_hotplug=/etc/hotplug.d/iface/40-tailscale
legacy_backup_dir=/etc/tailscale/luci-app-tailscale-legacy
[ -n "$${IPKG_INSTROOT}" ] && exit 0
disable_legacy_hotplug() {
local backup_file
[ -e "$${legacy_hotplug}" ] || [ -L "$${legacy_hotplug}" ] || return 0
backup_file="$${legacy_backup_dir}/40-tailscale.disabled.$$(date +%Y%m%d%H%M%S).$$$$"
if mkdir -p "$${legacy_backup_dir}" 2>/dev/null && cp -p "$${legacy_hotplug}" "$${backup_file}"; then
:
else
rm -f "$${backup_file}" 2>/dev/null || true
fi
# Disabling the duplicate starter is mandatory even when its backup fails.
# chmod is a last-resort guard for a transient remove failure; package
# extraction will subsequently remove the obsolete package-owned path.
rm -f "$${legacy_hotplug}" || chmod 000 "$${legacy_hotplug}" || return 1
}
disable_legacy_hotplug || exit 1
# 若系统内无任何相关配置文件,说明是全新安装,直接退出让包管理器正常解压释放默认配置
[ -f /etc/config/tailscale ] || [ -f /etc/config/tailscale_openclash ] || [ -f /etc/config/tailscale_policy_routing ] || exit 0
[ -f "$${state_dir}/.complete" ] && exit 0
clear_state_dir() {
rm -f "$${1}/tailscale" "$${1}/tailscale_openclash" \
"$${1}/tailscale_openclash.absent" "$${1}/tailscale_policy_routing" \
"$${1}/.complete"
rmdir "$${1}" 2>/dev/null || [ ! -e "$${1}" ]
}
clear_state_dir "$${state_dir}" || exit 1
clear_state_dir "$${state_pending}" || exit 1
umask 077
mkdir "$${state_pending}" || exit 1
chmod 700 "$${state_pending}" || exit 1
if [ -f /etc/config/tailscale ]; then
cp /etc/config/tailscale "$${state_pending}/tailscale" || exit 1
fi
if [ -f /etc/config/tailscale_openclash ]; then
cp /etc/config/tailscale_openclash "$${state_pending}/tailscale_openclash" || exit 1
fi
if [ -f /etc/config/tailscale_policy_routing ]; then
cp /etc/config/tailscale_policy_routing "$${state_pending}/tailscale_policy_routing" || exit 1
fi
: >"$${state_pending}/.complete" || exit 1
mv "$${state_pending}" "$${state_dir}" || exit 1
exit 0
define Package/luci-app-tailscale/conffiles
/etc/config/tailscale
/etc/config/tailscale_openclash
/etc/config/tailscale_policy_routing
endef
define Package/luci-app-tailscale/prerm
#!/bin/sh
state_dir=/etc/.luci-app-tailscale-upgrade
[ -n "$${IPKG_INSTROOT}" ] && exit 0
case "$${2:-}" in
remove|[0-9]*) ;;
*)
# default_prerm sources this script for opkg, while OpenWrt 24.10
# apk runs pre-deinstall directly with the old package version in $1.
case "$${1:-}" in
[0-9]*) ;;
*) exit 0 ;;
esac
;;
esac
if [ -x /usr/sbin/tailscale_policy_routing ]; then
/usr/sbin/tailscale_policy_routing cleanup >/dev/null 2>&1 || true
fi
if [ -z "$${IPKG_INSTROOT}" ] && [ -x /usr/sbin/tailscale_policy_routing ]; then
/usr/sbin/tailscale_policy_routing cleanup >/dev/null 2>&1 || exit 1
if [ -x /etc/init.d/tailscale-policy-routing ]; then
/etc/init.d/tailscale-policy-routing stop >/dev/null 2>&1 || true
/etc/init.d/tailscale-policy-routing disable >/dev/null 2>&1 || true
fi
if [ -z "$${IPKG_INSTROOT}" ] && [ -x /etc/init.d/tailscale-policy-routing ]; then
/etc/init.d/tailscale-policy-routing disable >/dev/null 2>&1 || exit 1
fi
if [ -z "$${IPKG_INSTROOT}" ] && [ -x /usr/sbin/tailscale_openclash_bypass ]; then
if [ -x /usr/sbin/tailscale_openclash_bypass ]; then
/usr/sbin/tailscale_openclash_bypass cleanup >/dev/null 2>&1 || true
fi
if [ -z "$${IPKG_INSTROOT}" ] && [ -x /etc/init.d/tailscale-openclash-bypass ]; then
if [ -x /etc/init.d/tailscale-openclash-bypass ]; then
/etc/init.d/tailscale-openclash-bypass stop >/dev/null 2>&1 || true
/etc/init.d/tailscale-openclash-bypass disable >/dev/null 2>&1 || true
fi
if [ -z "$${IPKG_INSTROOT}" ] && [ -x /etc/init.d/tailscale ]; then
/etc/init.d/tailscale stop >/dev/null 2>&1 || exit 1
if [ -x /etc/init.d/tailscale ]; then
/etc/init.d/tailscale stop >/dev/null 2>&1 || true
fi
for cleanup_dir in "$${state_dir}" "$${state_dir}.pending"; do
rm -f "$${cleanup_dir}/tailscale" "$${cleanup_dir}/tailscale_openclash" \
"$${cleanup_dir}/tailscale_openclash.absent" "$${cleanup_dir}/tailscale_policy_routing" \
"$${cleanup_dir}/.complete"
rmdir "$${cleanup_dir}" 2>/dev/null || true
done
exit 0
endef
@@ -1,84 +1,30 @@
#!/bin/sh
UPGRADE_STATE_DIR="${UPGRADE_STATE_DIR:-/etc/.luci-app-tailscale-upgrade}"
TAILSCALE_SECRETS_BIN="${TAILSCALE_SECRETS_BIN:-/usr/sbin/tailscale_secrets}"
UCI_BIN="${UCI_BIN:-uci}"
OPENCLASH_BYPASS_INIT="${OPENCLASH_BYPASS_INIT:-/etc/init.d/tailscale-openclash-bypass}"
POLICY_ROUTING_INIT="${POLICY_ROUTING_INIT:-/etc/init.d/tailscale-policy-routing}"
LEGACY_ADGUARD_DNS_INIT="${LEGACY_ADGUARD_DNS_INIT:-/etc/init.d/tailscale-adguard-dns}"
LEGACY_ADGUARD_DNS_RC_DIR="${LEGACY_ADGUARD_DNS_RC_DIR:-/etc/rc.d}"
LEGACY_ADGUARD_DNS_BACKUP_DIR="${LEGACY_ADGUARD_DNS_BACKUP_DIR:-/etc/tailscale/luci-app-tailscale-legacy}"
LUCI_INDEX_CACHE="${LUCI_INDEX_CACHE:-/tmp/luci-indexcache}"
cleanup_upgrade_state() {
rm -f "$UPGRADE_STATE_DIR/tailscale" "$UPGRADE_STATE_DIR/tailscale_openclash" \
"$UPGRADE_STATE_DIR/tailscale_policy_routing" \
"$UPGRADE_STATE_DIR/.complete"
rmdir "$UPGRADE_STATE_DIR" 2>/dev/null || true
}
restore_upgrade_config() {
local source_file="$1" target_file="$2" temporary_file
[ -f "$source_file" ] || return 0
temporary_file="$target_file.luci-app-tailscale-upgrade.$$"
cp "$source_file" "$temporary_file" || return 1
mv "$temporary_file" "$target_file" || {
rm -f "$temporary_file"
return 1
}
}
cleanup_legacy_adguard_dns_service() {
local backup_dir link
[ -e "$LEGACY_ADGUARD_DNS_INIT" ] || return 0
backup_dir="$LEGACY_ADGUARD_DNS_BACKUP_DIR/tailscale-adguard-dns.$(date +%Y%m%d%H%M%S).$$"
mkdir -p "$backup_dir" || return 1
for link in "$LEGACY_ADGUARD_DNS_RC_DIR"/*tailscale-adguard-dns; do
[ -e "$link" ] || [ -L "$link" ] || continue
printf '%s\t%s\n' "${link##*/}" "$(readlink "$link" 2>/dev/null || true)" >>"$backup_dir/rc-links"
done
if [ -x "$LEGACY_ADGUARD_DNS_INIT" ]; then
"$LEGACY_ADGUARD_DNS_INIT" stop || return 1
"$LEGACY_ADGUARD_DNS_INIT" disable || true
fi
cp -p "$LEGACY_ADGUARD_DNS_INIT" "$backup_dir/init" || return 1
for link in "$LEGACY_ADGUARD_DNS_RC_DIR"/*tailscale-adguard-dns; do
[ -e "$link" ] || [ -L "$link" ] || continue
rm -f "$link" || return 1
done
mv "$LEGACY_ADGUARD_DNS_INIT" "$backup_dir/tailscale-adguard-dns.disabled" || return 1
}
if [ -f "$UPGRADE_STATE_DIR/.complete" ]; then
restore_upgrade_config "$UPGRADE_STATE_DIR/tailscale" /etc/config/tailscale || exit 1
restore_upgrade_config "$UPGRADE_STATE_DIR/tailscale_openclash" /etc/config/tailscale_openclash || exit 1
restore_upgrade_config "$UPGRADE_STATE_DIR/tailscale_policy_routing" /etc/config/tailscale_policy_routing || exit 1
cleanup_upgrade_state
elif [ -d "$UPGRADE_STATE_DIR" ]; then
cleanup_upgrade_state
# 初始化密钥存储
if [ -x "$TAILSCALE_SECRETS_BIN" ]; then
"$TAILSCALE_SECRETS_BIN" migrate || exit 1
fi
cleanup_legacy_adguard_dns_service || exit 1
"$TAILSCALE_SECRETS_BIN" migrate || exit 1
# 根据配置启用 OpenClash 旁路分流服务
openclash_bypass_enabled="$("$UCI_BIN" -q get tailscale_openclash.settings.enabled 2>/dev/null || true)"
if [ "$openclash_bypass_enabled" = '1' ] && [ -x "$OPENCLASH_BYPASS_INIT" ]; then
"$OPENCLASH_BYPASS_INIT" enable
"$OPENCLASH_BYPASS_INIT" start
fi
# 启用策略路由服务
if [ -x "$POLICY_ROUTING_INIT" ]; then
"$POLICY_ROUTING_INIT" enable
"$POLICY_ROUTING_INIT" start
fi
# 刷新 LuCI 缓存使菜单立即生效
rm -f "$LUCI_INDEX_CACHE"
exit 0