From 085188d2d137a2f1cd7f56685ce9d81702d41ccf Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 29 Sep 2026 07:32:36 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=8D=93=20Sync=202026-09-29=2007:32:36?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- luci-app-substore/CHANGELOG.md | 36 +++ luci-app-substore/Makefile | 4 +- luci-app-substore/README.en.md | 20 +- luci-app-substore/README.md | 16 +- luci-app-substore/docs/INSTALL.md | 6 +- .../usr/share/substore/output_singbox.lua | 65 +++++- .../root/usr/share/substore/output_v2ray.lua | 74 +++++- .../root/usr/share/substore/util.lua | 33 ++- .../tests/output_formats_test.lua | 7 +- .../tests/output_full_config_test.lua | 220 ++++++++++++++++++ luci-app-substore/tests/ssr_test.lua | 15 +- luci-app-tailscale/Makefile | 116 ++------- .../root/etc/uci-defaults/40_luci-tailscale | 68 +----- 13 files changed, 488 insertions(+), 192 deletions(-) create mode 100644 luci-app-substore/tests/output_full_config_test.lua diff --git a/luci-app-substore/CHANGELOG.md b/luci-app-substore/CHANGELOG.md index da97075e4..4f3456db4 100644 --- a/luci-app-substore/CHANGELOG.md +++ b/luci-app-substore/CHANGELOG.md @@ -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`) diff --git a/luci-app-substore/Makefile b/luci-app-substore/Makefile index 8fd0ca7e7..23a704e44 100644 --- a/luci-app-substore/Makefile +++ b/luci-app-substore/Makefile @@ -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 diff --git a/luci-app-substore/README.en.md b/luci-app-substore/README.en.md index bd4c83a7f..a04d95c03 100644 --- a/luci-app-substore/README.en.md +++ b/luci-app-substore/README.en.md @@ -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**. diff --git a/luci-app-substore/README.md b/luci-app-substore/README.md index af3671223..5267881ae 100644 --- a/luci-app-substore/README.md +++ b/luci-app-substore/README.md @@ -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 菜单打开:**服务 → 订阅**。 diff --git a/luci-app-substore/docs/INSTALL.md b/luci-app-substore/docs/INSTALL.md index 9be8319d0..3b220541c 100644 --- a/luci-app-substore/docs/INSTALL.md +++ b/luci-app-substore/docs/INSTALL.md @@ -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: diff --git a/luci-app-substore/root/usr/share/substore/output_singbox.lua b/luci-app-substore/root/usr/share/substore/output_singbox.lua index a0d16bbbd..4ec53766b 100644 --- a/luci-app-substore/root/usr/share/substore/output_singbox.lua +++ b/luci-app-substore/root/usr/share/substore/output_singbox.lua @@ -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 \ No newline at end of file diff --git a/luci-app-substore/root/usr/share/substore/output_v2ray.lua b/luci-app-substore/root/usr/share/substore/output_v2ray.lua index 40fefbed6..07b7035cc 100644 --- a/luci-app-substore/root/usr/share/substore/output_v2ray.lua +++ b/luci-app-substore/root/usr/share/substore/output_v2ray.lua @@ -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 \ No newline at end of file diff --git a/luci-app-substore/root/usr/share/substore/util.lua b/luci-app-substore/root/usr/share/substore/util.lua index 7ee5b0b54..735fdb6fe 100644 --- a/luci-app-substore/root/usr/share/substore/util.lua +++ b/luci-app-substore/root/usr/share/substore/util.lua @@ -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 \ No newline at end of file diff --git a/luci-app-substore/tests/output_formats_test.lua b/luci-app-substore/tests/output_formats_test.lua index 428d17f81..1f873630f 100644 --- a/luci-app-substore/tests/output_formats_test.lua +++ b/luci-app-substore/tests/output_formats_test.lua @@ -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") diff --git a/luci-app-substore/tests/output_full_config_test.lua b/luci-app-substore/tests/output_full_config_test.lua new file mode 100644 index 000000000..438378e24 --- /dev/null +++ b/luci-app-substore/tests/output_full_config_test.lua @@ -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) diff --git a/luci-app-substore/tests/ssr_test.lua b/luci-app-substore/tests/ssr_test.lua index c3c291ddb..6e2fa0255 100644 --- a/luci-app-substore/tests/ssr_test.lua +++ b/luci-app-substore/tests/ssr_test.lua @@ -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"))) diff --git a/luci-app-tailscale/Makefile b/luci-app-tailscale/Makefile index c69384032..95321f5ac 100644 --- a/luci-app-tailscale/Makefile +++ b/luci-app-tailscale/Makefile @@ -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 diff --git a/luci-app-tailscale/root/etc/uci-defaults/40_luci-tailscale b/luci-app-tailscale/root/etc/uci-defaults/40_luci-tailscale index c925345de..f7fe9b1d9 100755 --- a/luci-app-tailscale/root/etc/uci-defaults/40_luci-tailscale +++ b/luci-app-tailscale/root/etc/uci-defaults/40_luci-tailscale @@ -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