🐤 Sync 2026-09-29 08:28:44

This commit is contained in:
github-actions[bot] committed 2026-09-29 08:28:44 +08:00
1 parent 839e7865e2
commit 746e1d916c
99 files changed
+3036 -17584

No files matched your search

+30
View File
@@ -2,6 +2,36 @@
All notable changes to this project will be documented in this file.
## [2.4.0-r2] - 修复 vmess 加密方式与 TLS 层混淆
- **修复严重缺陷**:经典 vmess 分享链接 `vmess://base64(json)` 的 `scy` 是**加密方式**
(`auto` / `aes-128-gcm` / `chacha20-poly1305` / `none` / `zero`),`tls` 才是 **TLS 层**
(`"tls"` 启用,`""` 不启用)。解析器此前把 `scy` 写进了统一模型的 `security` 字段,
而该字段在项目其余各处一律表示 TLS 层(`node.lua` DEFAULTS、vless/trojan URI 的
`security=`、Xray `streamSettings.security`、Clash `tls: true`)
- 后果:**一个不启用 TLS 的节点被三个目标同时误判为启用 TLS**,而这是机场订阅最常见的形态
- sing-box 多出 `"tls": {}`,同时 `security` 被写成加密方式
- V2Ray `streamSettings.security: "auto"` —— **非法取值,Xray 会拒绝启动**
- Clash.Meta 多出 `tls: true`;Surge 家族多出 `tls=true`
- 统一模型新增 `cipher` 字段专表 vmess 加密方式,`security` 回归纯 TLS 层语义
- 解析侧:`parser.lua` 经典 vmess JSON 改为 `cipher = scy`、`security` 由 `tls` 推导;
`parser_json_config.lua` 的 sing-box 导入改为 vmess 的 `security` → `cipher`,
TLS 由 `tls` 对象表达(并补齐 `alpn` / `insecure` → `skip-cert-verify` 的反向映射);
`parser.lua` 简易 YAML 回退路径同步修正
- 输出侧:`output_singbox.lua` / `output_v2ray.lua` / `output_uri.lua` 的 vmess 加密方式
改取 `cipher`;`output_clash_meta.lua` 原本就取 `cipher`,此前因 `cipher` 从未被写入而
恒回退到 `auto`(`cipher: aes-128-gcm` 这类非默认值会被静默丢弃,本次一并修复)
- **修复 `tls` 字段的真值陷阱**:空串 `""` 在 Lua 中为真值,`tls: ""`(经典 vmess JSON 表示
「不启用 TLS」的标准写法)会被 `output_formats.lua` / `output_clash_meta.lua` 的
`if node.tls then` 误判为启用 TLS。`node.normalize` 现将 `""` / `"none"` / `"false"`
归一为 `nil`、`"true"`(简易 YAML 解析器的字符串布尔)归一为 `true`,并把 `tls` 统一
落到权威字段 `security`
- **非法加密方式不再写入配置**:新增 `node.VMESS_CIPHERS` 白名单,非白名单取值
(如被第三方工具误写成 `"tls"` 的)在归一化时丢弃,输出端回退到 `auto`,
避免生成客户端拒绝加载的配置
- 新增测试 `tests/vmess_cipher_test.lua`(47 项),覆盖上述全部路径与分享链接回环
- 版本号 2.4.0-r1 → 2.4.0-r2;README.md / README.en.md / docs/INSTALL.md 同步
## [2.4.0-r1] - sing-box / V2Ray 输出完整配置
- ⚠️ **破坏性变更**:`target=singbox` 与 `target=v2ray` 由「仅含 `outbounds` 的片段」
+1 -1
View File
@@ -10,7 +10,7 @@ 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.4.0
PKG_RELEASE:=7
PKG_RELEASE:=8
LUCI_DEPENDS:=+luci-lua-runtime +luci-compat
+3 -3
View File
@@ -93,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.4.0-r1`).
> [Makefile](Makefile) (currently `2.4.0-r2`).
opkg (OpenWrt / ImmortalWrt 24.10 and earlier):
```bash
opkg install luci-app-substore-2.4.0-r1.ipk
opkg install luci-app-substore-2.4.0-r2.ipk
```
apk (OpenWrt / ImmortalWrt 25.12+):
```bash
apk add --allow-untrusted luci-app-substore-2.4.0-r1.apk
apk add --allow-untrusted luci-app-substore-2.4.0-r2.apk
```
Then open LuCI: **Services → Subscriptions**.
+3 -3
View File
@@ -80,18 +80,18 @@
## 安装
> 包名中的版本号必须与 [Makefile](Makefile) 的 `PKG_VERSION` / `PKG_RELEASE` 保持一致
> (当前 `2.4.0-r1`)。
> (当前 `2.4.0-r2`)。
opkg(OpenWrt / ImmortalWrt 24.10 及更早):
```bash
opkg install luci-app-substore-2.4.0-r1.ipk
opkg install luci-app-substore-2.4.0-r2.ipk
```
apk(OpenWrt / ImmortalWrt 25.12+):
```bash
apk add --allow-untrusted luci-app-substore-2.4.0-r1.apk
apk add --allow-untrusted luci-app-substore-2.4.0-r2.apk
```
然后在 LuCI 菜单打开:**服务 → 订阅**。
+1 -1
View File
@@ -10,7 +10,7 @@ OpenWrt / ImmortalWrt 原生 LuCI 机场订阅管理工具。参考 Sub-Store
- 订阅管理:多订阅源增删改查、手动/定时更新、更新时间/节点数/状态展示
- 节点管理:解析常见代理协议、查看/搜索/筛选/排序/重命名/去重
- 订阅处理:按条件筛选、去重、重命名、排序、多订阅合并、生成客户端订阅
- 输出格式:13 种全量实现(Plain JSON / Stash / Clash.Meta / Surfboard / Surge / SurgeMac / Loon / Egern / Shadowrocket / QX / sing-box / V2Ray / V2Ray URI)
- 输出格式:15 种全量实现(Plain JSON / Stash / Clash.Meta / Clash 原版 / Surfboard / Surge / SurgeMac / Loon / Egern / Shadowrocket / QX / sing-box / V2Ray / V2Ray URI / WireGuard `.conf`)
- 订阅链接:转换结果以订阅链接形式下发,供 Passwall / OpenClash 等拉取
- 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.4.0-r1)。
> 包名中的版本号需与 Makefile 的 `PKG_VERSION` / `PKG_RELEASE` 保持同步(当前 2.4.0-r2)。
## Install (opkg — OpenWrt / ImmortalWrt 24.10 及更早)
```bash
opkg install luci-app-substore-2.4.0-r1.ipk
opkg install luci-app-substore-2.4.0-r2.ipk
```
## Install (apk — OpenWrt / ImmortalWrt 25.12+)
```bash
apk add --allow-untrusted luci-app-substore-2.4.0-r1.apk
apk add --allow-untrusted luci-app-substore-2.4.0-r2.apk
```
Refresh LuCI:
+4 -2
View File
@@ -9,7 +9,9 @@
| `converter_*_test.lua` | 协议转换、URL 模板渲染 |
| `node_*_test.lua` | 节点模型扩展、分组、重命名 |
| `output_clash_meta_test.lua` | Clash.Meta / Mihomo YAML 生成 |
| `output_formats_test.lua` | 13 种目标格式统一分发 |
| `output_formats_test.lua` | 15 种目标格式统一分发 |
| `output_full_config_test.lua` | sing-box / V2Ray 完整配置输出 |
| `vmess_cipher_test.lua` | vmess 加密方式(cipher)与 TLS 层(security)不混淆 |
| `core_link_test.lua` | 订阅 token + generate_link 链接生成 |
| `parser_clash_yaml_test.lua` | Clash YAML 解析 |
| `parser_json_config_test.lua` | sing-box / V2Ray / Clash JSON 解析 |
@@ -22,7 +24,7 @@
1. 编译安装到目标设备
2. 添加订阅 URL,手动更新,验证节点数
3. 节点浏览:筛选、排序
4. 输出生成:13 种格式下拉均可生成
4. 输出生成:15 种格式下拉均可生成
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` 生成
@@ -14,6 +14,14 @@ local DEFAULTS = {
trojan = { security = "tls" },
}
-- vmess 加密方式(cipher)白名单。sing-box 的 vmess.security、Xray 的
-- users[].security、Clash 的 vmess.cipher 取同一组值;写入非法值会让客户端
-- 拒绝整份配置,因此归一化时丢弃未知取值,由输出端回退到 "auto"。
M.VMESS_CIPHERS = {
auto = true, none = true, zero = true,
["aes-128-gcm"] = true, ["chacha20-poly1305"] = true,
}
-- 判断节点是否拥有某标签;兼容 tags 为数组({"fast"})或 set({fast=true})
local function has_tag(node, tag)
local t = node and node.tags
@@ -33,6 +41,33 @@ function M.normalize(node)
for k, v in pairs(d) do
if node[k] == nil then node[k] = v end
end
-- tls 的空串 / "none" / "false" 在 Lua 里都是真值,会被下游的
-- `if node.tls then` 误判为「启用 TLS」(经典 vmess JSON 的 tls:"" 即此例),
-- 统一归一为 nil
if node.tls == "" or node.tls == "none" or node.tls == "false" then
node.tls = nil
elseif node.tls == "true" then
-- 简易 YAML 解析器把 tls: true 读成字符串 "true"
node.tls = true
end
-- tls 与 security 是同一语义(TLS 层)的两种写法,统一落到 security;
-- 已显式给出 security(含 DEFAULTS 补的 "none")时不覆盖
if node.security == nil or node.security == "none" then
if node.tls == true then
node.security = "tls"
elseif node.tls == "tls" or node.tls == "reality" then
node.security = node.tls
end
end
-- vmess 的加密方式存于 cipher,与 TLS 层(security)无关;
-- 非白名单取值(例如被误写成 "tls")直接丢弃,避免输出非法配置
if node.proto == "vmess" and node.cipher ~= nil and not M.VMESS_CIPHERS[node.cipher] then
node.cipher = nil
end
if node.name == nil or node.name == "" then
node.name = (node.server or "") .. ":" .. tostring(node.port or "")
end
@@ -101,7 +101,8 @@ function M.to_outbound(n, tag)
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
-- 此处的 security 是 vmess 加密方式,取 cipher 而非 TLS 层
o.security = n.cipher or "auto"
if n.flow then o.flow = n.flow end
elseif stype == "vless" then
o.uuid = n.uuid or ""
@@ -39,12 +39,15 @@ function M.to_share_uri(n)
port = tostring(port),
id = n.uuid or "",
aid = tostring(n.alterId or n.aid or 0),
scy = n.security or "auto",
-- scy 是 vmess 加密方式,取 cipher 而非 TLS 层
scy = n.cipher 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 "",
-- 经典 vmess JSON 的 tls 字段是 TLS 层("tls" 或 ""),
-- security 经 node.normalize 归一后是唯一权威来源
tls = (n.security and n.security ~= "none") and "tls" or "",
}
return "vmess://" .. util.base64_encode(util.json_encode(json))
end
@@ -72,7 +72,8 @@ function M.to_outbound(n, tag)
users = { {
id = n.uuid or "",
alterId = tonumber(n.alterId or n.aid or 0),
security = n.security or "auto",
-- users[].security 是 vmess 加密方式,取 cipher 而非 TLS 层
security = n.cipher or "auto",
} },
} }
elseif proto == "vless" then
@@ -279,7 +279,11 @@ local function parse_vmess(uri, body)
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,
net = j.net, type = j.type,
-- scy 是 vmess 加密方式(cipher),不是 TLS 层;TLS 由 tls 字段表达
-- ("tls" 表示启用,"" 表示不启用)
cipher = j.scy or j.security,
security = (j.tls == "tls" or j.tls == true) and "tls" or nil,
raw = uri,
})
return out
@@ -637,8 +641,11 @@ local function parse_yaml_content(content)
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,
-- Clash 的 cipher 是 vmess 加密方式;tls 才是 TLS 层,
-- 交由 node.normalize 归一到 security
cipher = (proto == "vmess") and n.cipher or nil,
tls = n.tls,
security = n.security,
sni = n.sni or n.servername,
alterId = tonumber(n.alterId),
}
@@ -102,17 +102,26 @@ function M.parse_singbox_json(content)
node_data.password = outbound.password
end
-- sing-box 用 tls 对象表达 TLS 层(enabled 缺省即 true)
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
if outbound.tls.enabled ~= false then
node_data.security = "tls"
end
if outbound.tls.alpn then
node_data.alpn = outbound.tls.alpn
end
if outbound.tls.insecure ~= nil then
node_data["skip-cert-verify"] = outbound.tls.insecure
end
end
if outbound.security then
node_data.security = outbound.security
-- sing-box 只有 vmess 出站有 security 字段,且它是加密方式
-- (cipher),不是 TLS 层;TLS 由上面的 tls 对象表达
if outbound.security and proto == "vmess" then
node_data.cipher = outbound.security
end
if outbound.network then
node_data.net = outbound.network
@@ -0,0 +1,188 @@
-- vmess_cipher_test.lua — vmess 加密方式(cipher)与 TLS 层(security)不得混淆
-- 用法:lua5.1 tests/vmess_cipher_test.lua
--
-- 背景:经典 vmess 分享链接 vmess://base64(json) 用 scy 表示**加密方式**
-- (auto / aes-128-gcm / chacha20-poly1305 / none / zero),用 tls 表示 **TLS 层**
-- ("tls" 启用,"" 不启用)。而统一模型里 security 一直是 TLS 层
-- (node.lua DEFAULTS、vless/trojan URI 的 security=、Xray streamSettings.security、
-- Clash tls: true)。此前解析器把 scy 写进了 security,导致一个不启用 TLS 的节点
-- 被三个目标同时误判为启用 TLS:
-- sing-box → "tls":{} (多余且错误)
-- V2Ray → streamSettings.security: "auto"(非法取值,Xray 拒绝启动)
-- Clash.Meta→ tls: true (多余且错误)
-- 本测试锁定修复后的行为。
package.path = "./root/usr/share/?.lua;" .. package.path
local util = require("substore.util")
local node = require("substore.node")
local parser = require("substore.parser")
local json_parser = require("substore.parser_json_config")
local clash_parser = require("substore.parser_clash_yaml")
local output = require("substore.output")
local 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
local function vmess_link(j)
return "vmess://" .. util.base64_encode(util.json_encode(j))
end
-- ---------- 1. 不启用 TLS:scy=auto, tls="" ----------
-- 这是机场订阅最常见的形态
local plain = parser.parse_uri(vmess_link({
v = "2", ps = "PLAIN", add = "1.1.1.1", port = "443",
id = "11111111-1111-1111-1111-111111111111", aid = "0",
scy = "auto", net = "tcp", type = "none", host = "", path = "", tls = "",
}))
check("plain parses", plain ~= nil and plain.proto == "vmess")
check("plain security is none (not the cipher)", plain.security == "none")
check("plain cipher is auto", plain.cipher == "auto")
check("plain tls normalized to nil", plain.tls == nil)
local sb = util.json_decode(output.generate({ plain }, "singbox"))
check("singbox no spurious tls", sb.outbounds[1].tls == nil)
check("singbox security is cipher auto", sb.outbounds[1].security == "auto")
local v2 = util.json_decode(output.generate({ plain }, "v2ray"))
check("v2ray no spurious streamSettings.security", v2.outbounds[1].streamSettings.security == nil)
check("v2ray users security is cipher auto", v2.outbounds[1].settings.vnext[1].users[1].security == "auto")
local cm = output.generate({ plain }, "clashmeta")
check("clashmeta no spurious tls: true", cm:find("tls: true", 1, true) == nil)
check("clashmeta cipher auto", cm:find("cipher: auto", 1, true) ~= nil)
local surge = output.generate({ plain }, "surge")
check("surge no spurious tls=true", surge:find("tls=true", 1, true) == nil)
-- ---------- 2. 启用 TLS + 非默认加密方式 ----------
local sec = parser.parse_uri(vmess_link({
v = "2", ps = "SEC", add = "2.2.2.2", port = "443",
id = "22222222-2222-2222-2222-222222222222", aid = "0",
scy = "aes-128-gcm", net = "ws", type = "none", host = "", path = "/p",
tls = "tls", sni = "a.example.com",
}))
check("tls security is tls", sec.security == "tls")
check("tls cipher is aes-128-gcm", sec.cipher == "aes-128-gcm")
local sb2 = util.json_decode(output.generate({ sec }, "singbox"))
check("singbox tls object present", type(sb2.outbounds[1].tls) == "table")
check("singbox security is the cipher", sb2.outbounds[1].security == "aes-128-gcm")
local v22 = util.json_decode(output.generate({ sec }, "v2ray"))
check("v2ray streamSettings.security is tls", v22.outbounds[1].streamSettings.security == "tls")
check("v2ray users security is the cipher", v22.outbounds[1].settings.vnext[1].users[1].security == "aes-128-gcm")
local cm2 = output.generate({ sec }, "clashmeta")
check("clashmeta tls: true", cm2:find("tls: true", 1, true) ~= nil)
check("clashmeta cipher preserved", cm2:find("cipher: aes-128-gcm", 1, true) ~= nil)
-- ---------- 3. 分享链接回环:加密方式与 TLS 层都要保住 ----------
local back = parser.parse_uri(uri.to_share_uri(sec))
check("roundtrip security tls", back.security == "tls")
check("roundtrip cipher aes-128-gcm", back.cipher == "aes-128-gcm")
local back_plain = parser.parse_uri(uri.to_share_uri(plain))
check("roundtrip plain security none", back_plain.security == "none")
check("roundtrip plain cipher auto", back_plain.cipher == "auto")
-- ---------- 4. 非法加密方式必须丢弃,而不是写进配置 ----------
-- 历史脏数据 / 第三方工具可能把 TLS 层写进 scy
local bad = parser.parse_uri(vmess_link({
v = "2", ps = "BAD", add = "3.3.3.3", port = "443",
id = "33333333-3333-3333-3333-333333333333", aid = "0",
scy = "tls", net = "tcp", tls = "",
}))
check("invalid cipher dropped", bad.cipher == nil)
check("invalid cipher does not become security", bad.security == "none")
check("invalid cipher falls back to auto on output",
util.json_decode(output.generate({ bad }, "singbox")).outbounds[1].security == "auto")
-- ---------- 5. node.normalize 的 tls 归一化 ----------
local function norm(t)
local n = node.normalize({ proto = "vmess", server = "s", port = 1, tls = t })
return n.tls, n.security
end
local t1, s1 = norm("")
check("normalize tls \"\" -> nil", t1 == nil and s1 == "none")
local t2, s2 = norm("none")
check("normalize tls \"none\" -> nil", t2 == nil and s2 == "none")
local t3, s3 = norm("false")
check("normalize tls \"false\" -> nil", t3 == nil and s3 == "none")
local t4, s4 = norm(true)
check("normalize tls true -> security tls", t4 == true and s4 == "tls")
local t5, s5 = norm("true")
check("normalize tls \"true\" (flat yaml) -> security tls", t5 == true and s5 == "tls")
local t6, s6 = norm("reality")
check("normalize tls reality -> security reality", t6 == "reality" and s6 == "reality")
-- ---------- 6. sing-box JSON 导入:security 是 cipher,TLS 由 tls 对象表达 ----------
local sb_nodes = json_parser.parse_singbox_json([[{"outbounds":[
{"tag":"a","type":"vmess","server":"1.1.1.1","server_port":443,"uuid":"u","security":"chacha20-poly1305",
"tls":{"server_name":"a.example.com"}},
{"tag":"b","type":"vmess","server":"2.2.2.2","server_port":443,"uuid":"u","security":"auto"}
]}]])
check("singbox import count", sb_nodes and #sb_nodes == 2)
check("singbox import cipher from security", sb_nodes[1].cipher == "chacha20-poly1305")
check("singbox import tls object -> security", sb_nodes[1].security == "tls")
check("singbox import sni from tls", sb_nodes[1].sni == "a.example.com")
check("singbox import no-tls node security none", sb_nodes[2].security == "none")
check("singbox import no-tls node has no tls", sb_nodes[2].tls == nil)
check("singbox import roundtrip keeps tls",
util.json_decode(output.generate({ sb_nodes[1] }, "singbox")).outbounds[1].tls ~= nil)
-- ---------- 7. Clash YAML 导入:cipher 是加密方式,tls 是 TLS 层 ----------
local cy = clash_parser.parse([[
proxies:
- name: CY
type: vmess
server: 4.4.4.4
port: 443
uuid: 44444444-4444-4444-4444-444444444444
alterId: 0
cipher: chacha20-poly1305
tls: true
network: ws
ws-opts:
path: /ws
]])
check("clash import count", cy and #cy == 1)
check("clash import cipher preserved", cy[1].cipher == "chacha20-poly1305")
check("clash import tls -> security", cy[1].security == "tls")
local cm3 = output.generate(cy, "clashmeta")
check("clash->clashmeta cipher preserved", cm3:find("cipher: chacha20-poly1305", 1, true) ~= nil)
check("clash->clashmeta tls: true", cm3:find("tls: true", 1, true) ~= nil)
-- ---------- 8. 简易 YAML 回退路径同样不得混淆 ----------
-- parse_yaml_content 把 tls: true 读成字符串 "true"
local flat = output.generate(parser.parse([[
proxies:
- name: FLAT
type: vmess
server: 5.5.5.5
port: 443
uuid: 55555555-5555-5555-5555-555555555555
cipher: auto
tls: ""
]]).nodes, "v2ray")
local flatd = util.json_decode(flat)
check("flat yaml no invalid streamSettings.security", flatd.outbounds[1].streamSettings.security == nil)
-- ---------- 9. 非 vmess 协议不受影响 ----------
local tro = node.normalize({ proto = "trojan", server = "6.6.6.6", port = 443, password = "p" })
check("trojan default security is tls", tro.security == "tls")
local vless = node.normalize({ proto = "vless", server = "7.7.7.7", port = 443, uuid = "u" })
check("vless default security is none", vless.security == "none")
print(string.format("\n%d passed, %d failed", passed, failed))
os.exit(failed == 0 and 0 or 1)
+77
View File
@@ -0,0 +1,77 @@
# Deploy Tooling
This folder contains a deploy workflow to push files on router for testing based on `bash + ssh/scp`.
## Files
- `.deploy/deploy.sh` - main deploy script (single pipeline mode)
- `.deploy/mapping.tsv` - explicit file mapping (`src -> dst -> mode`)
- `.deploy/target.env.example` - target configuration template
- `.deploy/target.env` - local router access settings (ignored by git)
## Requirements
- Bash (Linux/macOS, or Git Bash/WSL on Windows)
- OpenSSH client tools in PATH: `ssh`, `scp`
- Git in PATH (used to validate executable bit in repository index)
- SSH key access to router
## Mapping Format (`mapping.tsv`)
Each row contains three columns separated by one or more tabs:
1. local path relative to `.deploy/`
2. absolute remote path on router
3. mode (for example `644`, `755`)
Notes:
- Empty lines are allowed.
- Comment lines start with `#`.
- You can align columns using extra tab characters.
## Target Configuration (`target.env`)
Create local config first:
```bash
cp deploy/target.env.example deploy/target.env
```
Expected variables:
- `ROUTER_HOST`
- `ROUTER_USER`
- `ROUTER_PORT`
- `SSH_KEY_PATH` (optional)
## SSH Key Bootstrap
- If `SSH_KEY_PATH` is set, deploy uses that key.
- If `SSH_KEY_PATH` is empty, deploy uses project key `.deploy/.ssh/id_ed25519`.
- If the project key does not exist, deploy prompts to generate it on first run.
- Deploy tries to publish the public key to `/etc/dropbear/authorized_keys`.
- If auto-publish is not possible, deploy exits with exact manual commands to install the key on router.
- For non-interactive runs, set `.deploy_AUTO_YES=1` to auto-confirm key generation.
## Usage
Run from `luci-app-tailscale-ng`:
```bash
./deploy/deploy.sh
./deploy/deploy.sh deploy
./deploy/deploy.sh clean
```
Modes:
- `deploy` (default) - auth + copy + chmod + rpcd restart
- `clean` - auth + remove mapped target files only
## Behavior
- Running `./deploy/deploy.sh` is same as `./deploy/deploy.sh deploy`.
- Uploads mapped files via `scp -p`
- Applies `chmod` only when remote mode differs from expected mode
- Runs post-sync commands after copy/perms (deploy mode only)
- Post-sync commands are defined in `.deploy.sh` (currently `rpcd restart`)
+374
View File
@@ -0,0 +1,374 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
APP_ROOT="$(cd -- "$SCRIPT_DIR/.." && pwd)"
MAPPING_FILE="$SCRIPT_DIR/mapping.tsv"
TARGET_ENV_FILE="$SCRIPT_DIR/target.env"
DEFAULT_PROJECT_KEY="$SCRIPT_DIR/.ssh/id_ed25519"
RUNTIME_KEY_TO_CLEAN=""
declare -a MAP_SRC=()
declare -a MAP_DST=()
declare -a MAP_MODE=()
POST_SYNC_COMMANDS=(
"/etc/init.d/rpcd restart >/dev/null 2>&1 || true"
)
usage() {
cat <<'EOF'
Usage:
./deploy/deploy.sh
./deploy/deploy.sh deploy
./deploy/deploy.sh clean
EOF
}
log() { printf '%s\n' "$*"; }
step() { printf '[STEP] %s\n' "$*"; }
die() {
printf 'ERROR: %s\n' "$*" >&2
exit 1
}
require_cmd() { command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1"; }
trim() {
local s="$1"
s="${s#"${s%%[![:space:]]*}"}"
s="${s%"${s##*[![:space:]]}"}"
printf '%s' "$s"
}
is_tty() { [[ -t 0 && -t 1 ]]; }
remote_quote() { printf "'%s'" "${1//\'/\'\\\'\'}"; }
cleanup_runtime_key() {
if [[ -n "$RUNTIME_KEY_TO_CLEAN" && -f "$RUNTIME_KEY_TO_CLEAN" ]]; then
rm -f "$RUNTIME_KEY_TO_CLEAN" 2>/dev/null || true
fi
}
load_target_env() {
[[ -f "$TARGET_ENV_FILE" ]] || die "Target config is missing: $TARGET_ENV_FILE"
# shellcheck source=/dev/null
source "$TARGET_ENV_FILE"
ROUTER_HOST="$(trim "${ROUTER_HOST:-}")"
ROUTER_USER="$(trim "${ROUTER_USER:-root}")"
ROUTER_PORT="$(trim "${ROUTER_PORT:-22}")"
SSH_KEY_PATH="$(trim "${SSH_KEY_PATH:-}")"
[[ -n "$ROUTER_HOST" ]] || die "ROUTER_HOST is required in $TARGET_ENV_FILE"
[[ -n "$ROUTER_USER" ]] || die "ROUTER_USER is required in $TARGET_ENV_FILE"
[[ -n "$ROUTER_PORT" ]] || die "ROUTER_PORT is required in $TARGET_ENV_FILE"
}
resolve_key_path() {
local p
if [[ -n "$SSH_KEY_PATH" ]]; then
p="$SSH_KEY_PATH"
else
p="$DEFAULT_PROJECT_KEY"
fi
if [[ "$p" != /* ]]; then
p="$APP_ROOT/$p"
fi
printf '%s' "$p"
}
confirm_generate_key() {
local path="$1"
if [[ "${DEPLOY_AUTO_YES:-0}" == "1" ]]; then
return 0
fi
if ! is_tty; then
die "SSH key is missing: $path. Generate it manually: ssh-keygen -t ed25519 -N '' -f '$path' -C 'luci-app-tailscale-deploy'"
fi
printf 'Project SSH key not found: %s\n' "$path"
printf 'Generate it now? [y/N]: '
local answer
read -r answer
[[ "$answer" == "y" || "$answer" == "Y" ]] || die "SSH key generation declined. Aborting."
}
ensure_local_key() {
local key_path="$1"
local key_dir
key_dir="$(dirname -- "$key_path")"
mkdir -p "$key_dir"
if [[ ! -f "$key_path" ]]; then
confirm_generate_key "$key_path"
ssh-keygen -t ed25519 -N "" -f "$key_path" -C "luci-app-tailscale-deploy" >/dev/null
log "Generated SSH key: $key_path"
fi
[[ -f "${key_path}.pub" ]] || die "Public key is missing: ${key_path}.pub"
}
runtime_key_path() {
local key_path="$1"
local perm
perm="$(stat -c '%a' "$key_path" 2>/dev/null || true)"
if [[ "$perm" == "600" || "$perm" == "400" ]]; then
printf '%s' "$key_path"
return 0
fi
local runtime_dir="$HOME/.ssh"
local runtime_key="$runtime_dir/luci-app-tailscale-deploy-${$}.key"
mkdir -p "$runtime_dir"
cat "$key_path" > "$runtime_key"
chmod 700 "$runtime_dir" 2>/dev/null || true
chmod 600 "$runtime_key" 2>/dev/null || true
RUNTIME_KEY_TO_CLEAN="$runtime_key"
printf '%s' "$runtime_key"
}
build_ssh_opts() {
local key_path="$1"
SSH_OPTS=(
-p "$ROUTER_PORT"
-i "$key_path"
-o BatchMode=yes
-o ConnectTimeout=8
-o ConnectionAttempts=1
)
SCP_OPTS=(
-P "$ROUTER_PORT"
-i "$key_path"
-p
-o BatchMode=yes
-o ConnectTimeout=8
-o ConnectionAttempts=1
)
}
remote() { ssh "${SSH_OPTS[@]}" "$ROUTER_USER@$ROUTER_HOST" "$1"; }
print_manual_key_install() {
local key_path="$1"
local pub
pub="$(cat "${key_path}.pub")"
printf '\nManual SSH key installation required for %s@%s.\n' "$ROUTER_USER" "$ROUTER_HOST" >&2
printf 'Run on router:\n' >&2
printf 'mkdir -p /etc/dropbear\n' >&2
printf 'touch /etc/dropbear/authorized_keys\n' >&2
printf "grep -qxF %s /etc/dropbear/authorized_keys || echo %s >> /etc/dropbear/authorized_keys\n" "$(remote_quote "$pub")" "$(remote_quote "$pub")" >&2
printf 'chmod 700 /etc/dropbear\n' >&2
printf 'chmod 600 /etc/dropbear/authorized_keys\n\n' >&2
printf 'Public key:\n%s\n\n' "$pub" >&2
}
ensure_router_key_auth() {
local key_path="$1"
local pub
pub="$(cat "${key_path}.pub")"
if remote "true" >/dev/null 2>&1; then
return 0
fi
if ssh -p "$ROUTER_PORT" -o ConnectTimeout=8 -o ConnectionAttempts=1 "$ROUTER_USER@$ROUTER_HOST" \
"mkdir -p /etc/dropbear; touch /etc/dropbear/authorized_keys; grep -qxF $(remote_quote "$pub") /etc/dropbear/authorized_keys || echo $(remote_quote "$pub") >> /etc/dropbear/authorized_keys; chmod 700 /etc/dropbear; chmod 600 /etc/dropbear/authorized_keys" >/dev/null 2>&1; then
if remote "true" >/dev/null 2>&1; then
log "Project key published to router: ${key_path}.pub"
return 0
fi
fi
print_manual_key_install "$key_path"
die "Router key authentication is not configured for key: $key_path"
}
load_mapping() {
[[ -f "$MAPPING_FILE" ]] || die "Mapping file is missing: $MAPPING_FILE"
MAP_SRC=()
MAP_DST=()
MAP_MODE=()
local line_no=0 line src dst mode src_abs
while IFS= read -r line || [[ -n "$line" ]]; do
line_no=$((line_no + 1))
line="$(trim "$line")"
[[ -z "$line" ]] && continue
[[ "${line:0:1}" == "#" ]] && continue
IFS=$'\t' read -r src dst mode _extra <<<"$line"
src="$(trim "${src:-}")"
dst="$(trim "${dst:-}")"
mode="$(trim "${mode:-}")"
[[ -n "$src" && -n "$dst" && -n "$mode" ]] || die "Invalid mapping at line $line_no in $MAPPING_FILE"
[[ "$dst" == /* ]] || die "Destination path must be absolute at line $line_no: $dst"
[[ "$mode" =~ ^[0-7]{3,4}$ ]] || die "Invalid mode at line $line_no: $mode"
src_abs="$SCRIPT_DIR/$src"
[[ -f "$src_abs" ]] || die "Source file not found at line $line_no: $src_abs"
MAP_SRC+=("$src")
MAP_DST+=("$dst")
MAP_MODE+=("$mode")
done <"$MAPPING_FILE"
[[ ${#MAP_SRC[@]} -gt 0 ]] || die "Mapping has no entries: $MAPPING_FILE"
}
validate_git_exec_bits() {
local i src mode repo_rel git_mode
for ((i=0; i<${#MAP_SRC[@]}; i++)); do
src="${MAP_SRC[$i]}"
mode="${MAP_MODE[$i]}"
[[ "$mode" == "755" || "$mode" == "0755" ]] || continue
repo_rel="${src#../}"
git_mode="$(git -C "$APP_ROOT" ls-files --stage -- "$repo_rel" | awk '{print $1}')"
[[ -n "$git_mode" ]] || die "Expected executable file is not tracked by git: $repo_rel"
[[ "$git_mode" == "100755" ]] || die "Expected git mode 100755 for $repo_rel, got $git_mode"
done
}
ensure_remote_dirs() {
local i dir
for ((i=0; i<${#MAP_DST[@]}; i++)); do
dir="$(dirname -- "${MAP_DST[$i]}")"
remote "mkdir -p $(remote_quote "$dir")"
done
}
copy_files() {
local i src_abs dst
step "Copy: ${#MAP_SRC[@]} files -> target"
ensure_remote_dirs
for ((i=0; i<${#MAP_SRC[@]}; i++)); do
src_abs="$SCRIPT_DIR/${MAP_SRC[$i]}"
dst="${MAP_DST[$i]}"
log "COPY ${MAP_SRC[$i]} -> $dst"
scp "${SCP_OPTS[@]}" "$src_abs" "$ROUTER_USER@$ROUTER_HOST:$dst" >/dev/null
done
}
symbolic_to_octal() {
local s="$1" p d1=0 d2=0 d3=0
p="${s:1:9}"
[[ "${p:0:1}" == "r" ]] && d1=$((d1 + 4))
[[ "${p:1:1}" == "w" ]] && d1=$((d1 + 2))
[[ "${p:2:1}" =~ [xst] ]] && d1=$((d1 + 1))
[[ "${p:3:1}" == "r" ]] && d2=$((d2 + 4))
[[ "${p:4:1}" == "w" ]] && d2=$((d2 + 2))
[[ "${p:5:1}" =~ [xst] ]] && d2=$((d2 + 1))
[[ "${p:6:1}" == "r" ]] && d3=$((d3 + 4))
[[ "${p:7:1}" == "w" ]] && d3=$((d3 + 2))
[[ "${p:8:1}" =~ [xt] ]] && d3=$((d3 + 1))
printf '%d%d%d' "$d1" "$d2" "$d3"
}
remote_mode() {
local dst="$1" out
out="$(remote "if command -v stat >/dev/null 2>&1; then stat -c '%a' $(remote_quote "$dst") 2>/dev/null; else ls -ld $(remote_quote "$dst") 2>/dev/null | awk '{print \$1}'; fi" || true)"
out="$(printf '%s' "$out" | tr -d '\r\n')"
if [[ "$out" =~ ^[0-7]{3,4}$ ]]; then
printf '%s' "${out#0}"
return 0
fi
if [[ "$out" =~ ^[-dlcbps][rwxstST-]{9}$ ]]; then
symbolic_to_octal "$out"
return 0
fi
printf ''
}
set_perms() {
step "Permissions: set modes for ${#MAP_DST[@]} files on target"
local i dst expected actual normalized
for ((i=0; i<${#MAP_DST[@]}; i++)); do
dst="${MAP_DST[$i]}"
expected="${MAP_MODE[$i]}"
normalized="${expected#0}"
actual="$(remote_mode "$dst")"
if [[ -z "$actual" ]]; then
log "CHMOD $normalized $dst"
remote "chmod $expected $(remote_quote "$dst")"
continue
fi
if [[ "$actual" != "$normalized" ]]; then
log "CHMOD $normalized $dst"
remote "chmod $expected $(remote_quote "$dst")"
else
log "MODE_OK $normalized $dst"
fi
done
}
run_post_sync() {
step "Post: running post-sync commands"
local cmd
for cmd in "${POST_SYNC_COMMANDS[@]}"; do
log "POST $cmd"
remote "$cmd"
done
}
run_uci_defaults() {
step "Defaults: running /etc/uci-defaults/40_luci-app-tailscale-ng"
remote "if [ -f /etc/uci-defaults/40_luci-app-tailscale-ng ]; then sh /etc/uci-defaults/40_luci-app-tailscale-ng; else echo 'SKIP: /etc/uci-defaults/40_luci-app-tailscale-ng not found'; fi"
}
clean_target_files() {
step "Clean: remove mapped files on target"
local i dst
for ((i=0; i<${#MAP_DST[@]}; i++)); do
dst="${MAP_DST[$i]}"
log "REMOVE $dst"
remote "rm -f $(remote_quote "$dst")"
done
}
main() {
trap cleanup_runtime_key EXIT
local run_mode="deploy"
if [[ $# -gt 1 ]]; then
die "Too many arguments. Use: ./deploy/deploy.sh [deploy|clean]"
fi
if [[ $# -eq 1 ]]; then
case "$1" in
-h|--help)
usage
exit 0
;;
deploy|clean)
run_mode="$1"
;;
*)
die "Unknown mode: $1. Use: deploy or clean"
;;
esac
fi
require_cmd ssh
require_cmd scp
require_cmd git
load_mapping
validate_git_exec_bits
load_target_env
local active_key effective_key
active_key="$(resolve_key_path)"
ensure_local_key "$active_key"
effective_key="$(runtime_key_path "$active_key")"
build_ssh_opts "$effective_key"
ensure_router_key_auth "$active_key"
step "Auth: checking SSH access to $ROUTER_USER@$ROUTER_HOST"
remote "true" >/dev/null
if [[ "$run_mode" == "clean" ]]; then
clean_target_files
log "DONE: clean completed successfully"
return 0
fi
copy_files
set_perms
run_uci_defaults
run_post_sync
log "DONE: deploy pipeline completed successfully"
}
main "$@"
+10
View File
@@ -0,0 +1,10 @@
# local_path_from_deploy_dir<TAB+>remote_absolute_path<TAB+>mode
# You can use multiple TABs between columns for visual alignment.
../htdocs/luci-static/resources/view/tailscale-ng/index.js /www/luci-static/resources/view/tailscale-ng/index.js 644
../root/etc/config/luci-app-tailscale-ng /etc/config/luci-app-tailscale-ng 644
../root/etc/hotplug.d/iface/40-luci-app-tailscale-ng /etc/hotplug.d/iface/40-luci-app-tailscale-ng 755
../root/etc/init.d/luci-app-tailscale-ng /etc/init.d/luci-app-tailscale-ng 755
../root/etc/uci-defaults/40_luci-app-tailscale-ng /etc/uci-defaults/40_luci-app-tailscale-ng 755
../root/usr/share/luci/menu.d/luci-app-tailscale-ng.json /usr/share/luci/menu.d/luci-app-tailscale-ng.json 644
../root/usr/share/rpcd/acl.d/luci-app-tailscale-ng.json /usr/share/rpcd/acl.d/luci-app-tailscale-ng.json 644
1 # local_path_from_deploy_dir<TAB+>remote_absolute_path<TAB+>mode
2 # You can use multiple TABs between columns for visual alignment.
3 ../htdocs/luci-static/resources/view/tailscale-ng/index.js /www/luci-static/resources/view/tailscale-ng/index.js 644
4 ../root/etc/config/luci-app-tailscale-ng /etc/config/luci-app-tailscale-ng 644
5 ../root/etc/hotplug.d/iface/40-luci-app-tailscale-ng /etc/hotplug.d/iface/40-luci-app-tailscale-ng 755
6 ../root/etc/init.d/luci-app-tailscale-ng /etc/init.d/luci-app-tailscale-ng 755
7 ../root/etc/uci-defaults/40_luci-app-tailscale-ng /etc/uci-defaults/40_luci-app-tailscale-ng 755
8 ../root/usr/share/luci/menu.d/luci-app-tailscale-ng.json /usr/share/luci/menu.d/luci-app-tailscale-ng.json 644
9 ../root/usr/share/rpcd/acl.d/luci-app-tailscale-ng.json /usr/share/rpcd/acl.d/luci-app-tailscale-ng.json 644
@@ -0,0 +1,5 @@
ROUTER_HOST=192.168.1.1
ROUTER_USER=root
ROUTER_PORT=22
# Optional. If empty, deploy uses project key: deploy/.ssh/id_ed25519
# SSH_KEY_PATH=deploy/.ssh/id_ed25519
+19
View File
@@ -0,0 +1,19 @@
# Force LF line endings for all text files (required for OpenWRT)
* text=auto eol=lf
# Explicitly mark shell scripts
*.sh text eol=lf
**/init.d/* text eol=lf
**/hotplug.d/**/* text eol=lf
**/uci-defaults/* text eol=lf
# LuCI files
*.js text eol=lf
*.json text eol=lf
*.css text eol=lf
# Config files
**/config/* text eol=lf
# Documentation (can stay as-is)
*.md text eol=lf
+3
View File
@@ -0,0 +1,3 @@
.deploy/target.env
.deploy/.ssh/
.private
+674
View File
@@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 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 GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. 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
them 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 prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. 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.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey 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;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If 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 convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU 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 that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
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.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
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.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
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
state 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 3 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 does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program 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, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU 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. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
+17
View File
@@ -0,0 +1,17 @@
# SPDX-License-Identifier: GPL-3.0-only
#
# Copyright (C) 2026 vad-b
# Based on https://github.com/asvow/luci-app-tailscale
include $(TOPDIR)/rules.mk
LUCI_TITLE:=LuCI for Tailscale (NG)
LUCI_DEPENDS:=+tailscale
LUCI_PKGARCH:=all
PKG_VERSION:=0.1.1
PKG_RELEASE:=1
include $(TOPDIR)/feeds/luci/luci.mk
# call BuildPackage - OpenWrt buildroot signature
+110
View File
@@ -0,0 +1,110 @@
# luci-app-tailscale-ng
LuCI web interface for managing [Tailscale](https://tailscale.com/) on OpenWrt routers.
Inspired by [asvow/luci-app-tailscale](https://github.com/asvow/luci-app-tailscale), this project is a major redesign that eliminates conflicts with the standard OpenWrt `tailscale` package. See [Motivation](#motivation) for details.
## Quick Install
Auto-detects `opkg` / `apk` and installs the latest release:
```shell
wget -qO- https://raw.githubusercontent.com/vad-b/luci-app-tailscale-ng/main/install.sh | sh
```
After installation, navigate to **VPN -> Tailscale NG** in the LuCI web interface.
Need manual package install options instead? See [Manual Installation](#manual-installation).
## Screenshots
<table style="width: 100%; table-layout: fixed;">
<tr>
<td align="center" valign="top" width="33.33%"><strong>Authentication</strong></td>
<td align="center" valign="top" width="33.33%"><strong>Settings</strong></td>
<td align="center" valign="top" width="33.33%"><strong>Status</strong></td>
</tr>
<tr>
<td valign="top" width="33.33%"><img src="docs/images/tailscale-ng-auth.png" alt="Tailscale NG Authentication tab" width="100%"></td>
<td valign="top" width="33.33%"><img src="docs/images/tailscale-ng-settings.png" alt="Tailscale NG Settings tab" width="100%"></td>
<td valign="top" width="33.33%"><img src="docs/images/tailscale-ng-status.png" alt="Tailscale NG Status tab" width="100%"></td>
</tr>
</table>
## Motivation
### Problem
The original [asvow/luci-app-tailscale](https://github.com/asvow/luci-app-tailscale) project is excellent and inspired this work, but its integration model is tightly coupled to core files from the OpenWrt `tailscale` package, which leads to three operational drawbacks:
- **Invasive install:** During installation, `asvow/luci-app-tailscale` replaces core `tailscale` files (`/etc/init.d/tailscale` and `/etc/config/tailscale`) and takes ownership of them.
- **Breaking uninstallation:** When `asvow/luci-app-tailscale` is removed, those same files are deleted, leaving the stock `tailscale` package non-functional.
- **Restore normal operation:** To recover normal Tailscale operation, you must recreate the removed files manually or reinstall the `tailscale` package.
See [asvow/luci-app-tailscale#31](https://github.com/asvow/luci-app-tailscale/issues/31) for details.
### Solution
The goals of `luci-app-tailscale-ng` are:
- keep integration non-conflicting with the standard OpenWrt `tailscale` package
- make the OpenWrt Tailscale UI closer to the Tailscale management UI in pfSense and OPNsense
## How it works
**luci-app-tailscale-ng** takes a non-invasive approach:
- **Does not replace** any files from the `tailscale` package - the original `/etc/init.d/tailscale` and `/etc/config/tailscale` remain untouched
- **Adds its own** UCI config (`/etc/config/luci-app-tailscale-ng`) and init script (`/etc/init.d/luci-app-tailscale-ng`) that work alongside the standard ones
- **Manages `tailscale up` parameters** by reading settings from its own config and applying them via the `tailscale` CLI
- **Combines two configs** in the UI - settings from the standard `tailscale` config (port, state file, firewall mode, logging) and our extended config (auth, DNS, routing, exit nodes) are presented on a single page
This means you can install and remove the package at any time without breaking your Tailscale setup.
## Features
- Enable/disable Tailscale service with auto-start on boot
- Configure listen port, state file path, firewall mode
- Login status with one-click authentication link
- Configure control server URL and pre-authentication keys
- Accept DNS / Accept subnet routes
- Advertise as exit node or select a remote exit node (with auto-detection of exit nodes)
- Advertise local subnets (with auto-detection of LAN/WAN networks)
- Pass additional `tailscale up` flags
- Live status dashboard (tailscale status, IP, interface, netcheck, DNS)
- Automatic settings reapply on network interface changes via hotplug
> **Right now** this package provides Tailscale management in LuCI (service control, `tailscale up` settings, and status visibility). It does **not** automatically create Tailscale interfaces or firewall rules yet. See [Roadmap](#roadmap) for planned automation.
## Roadmap
- Auto-create network interfaces and firewall zones/rules for Tailscale
- Import existing Tailscale settings on first install (read current running parameters and populate the config)
- Backup and restore of the Tailscale state file
- Update Tailscale directly from the web UI (both standard and size-optimized builds for memory-constrained devices)
## Manual Installation
### Install with `opkg` (`.ipk`)
Download the latest `.ipk` package from [Releases](https://github.com/vad-b/luci-app-tailscale-ng/releases), upload it to the router's `/tmp` directory, then:
```shell
opkg install /tmp/luci-app-tailscale-ng_*.ipk
```
### Install with `apk` (`.apk`)
Download the latest `.apk` package from [Releases](https://github.com/vad-b/luci-app-tailscale-ng/releases), upload it to the router's `/tmp` directory, then:
```shell
apk add --allow-untrusted --upgrade /tmp/luci-app-tailscale-ng_*.apk
```
## Credits
- [asvow/luci-app-tailscale](https://github.com/asvow/luci-app-tailscale) - the project that inspired this work
## License
[GPL-3.0](LICENSE)
Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

@@ -0,0 +1,817 @@
/* SPDX-License-Identifier: GPL-3.0-only
*
* Copyright (C) 2024 asvow
* Copyright (C) 2026 vad-b
*/
'use strict';
'require form';
'require fs';
'require network';
'require poll';
'require rpc';
'require uci';
'require ui';
'require view';
// RPC
const callServiceList = rpc.declare({
object: 'service',
method: 'list',
params: ['name'],
expect: { '': {} }
});
const callInitAction = rpc.declare({
object: 'luci',
method: 'setInitAction',
params: ['name', 'action'],
expect: { result: false }
});
// Helpers
// Cached service list result to avoid duplicate calls
let cachedServiceList = null;
let latestTsInfo = null;
async function getServiceStatus() {
try {
cachedServiceList = await callServiceList('tailscale');
return cachedServiceList?.tailscale?.instances?.instance1?.running || false;
} catch (e) {
return false;
}
}
async function getInterfaceSubnets() {
try {
await uci.load('network');
const subnets = [];
uci.sections('network', 'interface', function(section) {
const name = section['.name'];
if (name === 'lan' || name === 'wan') {
const ipaddr = section.ipaddr;
const netmask = section.netmask || '255.255.255.0';
if (ipaddr && !ipaddr.includes(':')) {
const maskParts = netmask.split('.').map(Number);
const cidr = maskParts.reduce((acc, part) => acc + (part.toString(2).match(/1/g) || []).length, 0);
const ipParts = ipaddr.split('.').map(Number);
const subnetParts = ipParts.map((part, i) => part & maskParts[i]);
subnets.push(subnetParts.join('.') + '/' + cidr);
}
}
});
return [...new Set(subnets)];
} catch (e) {
return [];
}
}
async function getTailscaleInfo(isRunning) {
const info = {
isRunning: isRunning,
backendState: undefined,
authURL: undefined,
displayName: undefined,
onlineExitNodes: [],
subnetRoutes: []
};
try {
if (!isRunning)
return info;
const tailscaleRes = await fs.exec('/usr/sbin/tailscale', ['status', '--json']);
if (tailscaleRes.code === 0 && tailscaleRes.stdout) {
const status = JSON.parse(tailscaleRes.stdout.replace(/("\w+"):\s*(\d+)/g, '$1:"$2"'));
info.backendState = status.BackendState;
info.authURL = status.AuthURL;
if (info.backendState === 'Running' && status.Self && status.User) {
info.displayName = status.User[status.Self.UserID]?.DisplayName;
}
if (!status.AuthURL && status.BackendState === 'NeedsLogin') {
fs.exec('/usr/sbin/tailscale', ['login'])
.catch(function() {});
}
if (status.Peer) {
info.onlineExitNodes = Object.values(status.Peer)
.filter(peer => peer.ExitNodeOption && peer.Online)
.map(peer => peer.DNSName ? peer.DNSName : peer.HostName);
info.subnetRoutes = Object.values(status.Peer)
.flatMap(peer => peer.PrimaryRoutes || []);
}
}
} catch (e) {}
return info;
}
async function getStatusData() {
const data = {
tailscaleStatus: null,
tailscaleIp: null,
tailscaleInterface: null,
tailscaleNetcheck: null,
tailscaleDnsStatus: null
};
function getCommandOutput(res) {
if (!res) return null;
if (res.stdout && res.stdout.trim()) return res.stdout;
if (res.stderr && res.stderr.trim()) return res.stderr;
if (typeof res.code === 'number' && res.code !== 0) {
return _('Command exited with code ') + String(res.code);
}
return null;
}
try {
const [statusRes, ipRes, ifconfigRes, netcheckRes, dnsStatusRes] = await Promise.all([
fs.exec('/usr/sbin/tailscale', ['status']),
fs.exec('/usr/sbin/tailscale', ['ip']),
fs.exec('/sbin/ip', ['addr', 'show', 'tailscale0']),
fs.exec('/usr/sbin/tailscale', ['netcheck']),
fs.exec('/usr/sbin/tailscale', ['dns', 'status'])
]);
data.tailscaleStatus = getCommandOutput(statusRes);
data.tailscaleIp = getCommandOutput(ipRes);
data.tailscaleInterface = getCommandOutput(ifconfigRes);
data.tailscaleNetcheck = getCommandOutput(netcheckRes);
data.tailscaleDnsStatus = getCommandOutput(dnsStatusRes);
} catch (e) {
data.tailscaleStatus = _('Error: unable to load status data.');
}
return data;
}
async function handleServiceAction(action, message) {
ui.showModal(null, [E('p', { 'class': 'spinning' }, message)]);
await callInitAction('luci-app-tailscale-ng', action);
location.reload();
}
function handleLogoutAndClean() {
if (!confirm(_('Are you sure you want to log out and clean the local state?')))
return Promise.resolve();
ui.showModal(null, [E('p', { 'class': 'spinning' }, _('Logging out and cleaning state...'))]);
return fs.exec('/usr/sbin/tailscale', ['logout']).then(function() {
ui.hideModal();
location.reload();
}).catch(function(e) {
ui.hideModal();
ui.addNotification(null, E('p', {}, _('Error: ') + e.message), 'error');
});
}
function updateLogoutSection(info) {
const show = !!(info && info.backendState === 'Running');
const title = document.getElementById('ts-logout-title');
const row = document.getElementById('ts-logout-row');
if (title)
title.style.display = show ? '' : 'none';
if (row)
row.style.display = show ? '' : 'none';
}
function updateExitNodeOptions(info) {
const select = document.querySelector('select[name$=".exit_node"], select[id$=".exit_node"]');
if (!select)
return;
const prevValue = select.value;
const nodes = Array.isArray(info?.onlineExitNodes) ? info.onlineExitNodes : [];
while (select.options.length > 0)
select.remove(0);
select.add(new Option(_('-- None --'), ''));
nodes.forEach(function(node) {
select.add(new Option(node, node));
});
if (Array.from(select.options).some(function(opt) { return opt.value === prevValue; }))
select.value = prevValue;
else
select.value = '';
}
// Icons
const SVG_NS = 'http://www.w3.org/2000/svg';
function svgEl(tag, attrs, children) {
const el = document.createElementNS(SVG_NS, tag);
Object.entries(attrs || {}).forEach(([key, value]) => el.setAttribute(key, value));
(children || []).forEach(child => el.appendChild(child));
return el;
}
function createIcon(children) {
return svgEl('svg', {
xmlns: SVG_NS,
width: '16',
height: '16',
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
'stroke-width': '2',
'stroke-linecap': 'round',
'stroke-linejoin': 'round'
}, children);
}
const icons = {
restart: () => createIcon([
svgEl('path', { d: 'M21 12a9 9 0 1 1-9-9 9.75 9.75 0 0 1 6.74 2.74L21 8' }),
svgEl('path', { d: 'M21 3v5h-5' })
]),
stop: () => createIcon([
svgEl('circle', { cx: '12', cy: '12', r: '10' }),
svgEl('rect', { x: '9', y: '9', width: '6', height: '6', rx: '1' })
]),
start: () => createIcon([
svgEl('circle', { cx: '12', cy: '12', r: '10' }),
svgEl('polygon', { points: '10,8 16,12 10,16' })
]),
enable: () => createIcon([
svgEl('polygon', { points: '6,3 20,12 6,21' })
]),
disable: () => createIcon([
svgEl('rect', { x: '14', y: '4', width: '4', height: '16', rx: '1' }),
svgEl('rect', { x: '6', y: '4', width: '4', height: '16', rx: '1' })
])
};
function renderIconButton(icon, text, className, onClick) {
return E('button', {
'class': 'btn ' + className,
'click': onClick,
'style': 'display: inline-flex; align-items: center;'
}, [
E('span', { 'style': 'display: inline-flex; align-items: center; margin-right: 5px;' }, [icon]),
E('span', {}, text)
]);
}
function renderServiceStatusContent(isRunning, info) {
if (!isRunning)
return E('span', { 'style': 'font-weight: bold; color: #c00;' }, _('Not running'));
const nodes = [
E('span', { 'style': 'font-weight: bold; color: #080;' }, _('Running'))
];
if (info && info.backendState) {
nodes.push(E('span', {}, ', '));
if (info.backendState === 'Running') {
nodes.push(E('span', { 'style': 'font-weight: bold; color: #080;' }, _('Logged in')));
} else if (info.backendState === 'NeedsLogin') {
nodes.push(E('span', { 'style': 'font-weight: bold; color: #c00;' }, _('Logged out')));
} else {
nodes.push(E('span', { 'style': 'font-weight: bold; color: #c50;' }, _('Checking login...')));
}
}
return E('span', {}, nodes);
}
// Custom form options
const ServiceStatusValue = form.DummyValue.extend({
__name__: 'ServiceStatusValue',
isRunning: false,
tsInfo: null,
renderWidget: function() {
const isRunning = this.isRunning;
const info = this.tsInfo || null;
const statusSpan = E('div', {
'id': 'ts-service-status',
'style': 'display: inline-block; min-width: 170px; margin-right: 10px;'
}, [renderServiceStatusContent(isRunning, info)]);
const buttonsDiv = E('div', {
'id': 'ts-service-controls',
'style': 'display: inline-flex; gap: 5px;'
});
if (isRunning) {
buttonsDiv.appendChild(renderIconButton(
icons.restart(), _('Restart'), 'cbi-button-action',
ui.createHandlerFn(this, function() {
return handleServiceAction('restart', _('Restarting Tailscale service...'));
})
));
buttonsDiv.appendChild(renderIconButton(
icons.stop(), _('Stop'), 'cbi-button-negative',
ui.createHandlerFn(this, function() {
return handleServiceAction('stop', _('Stopping Tailscale service...'));
})
));
} else {
buttonsDiv.appendChild(renderIconButton(
icons.start(), _('Start'), 'cbi-button-positive',
ui.createHandlerFn(this, function() {
return handleServiceAction('start', _('Starting Tailscale service...'));
})
));
}
return E('div', { 'style': 'display: flex; align-items: center;' }, [statusSpan, buttonsDiv]);
}
});
const LoginStatusValue = form.DummyValue.extend({
__name__: 'LoginStatusValue',
tsInfo: null,
renderWidget: function() {
const info = this.tsInfo || {};
return E('div', { 'id': 'ts-login-status', 'style': 'line-height: 30px;' },
renderLoginStatusContent(info));
}
});
function renderLoginStatusContent(info) {
if (info.backendState === 'NeedsLogin' && info.authURL) {
return E('a', {
'href': info.authURL,
'target': '_blank',
'class': 'btn cbi-button-negative',
'style': 'font-weight: 700;'
}, _('Click here to log in'));
} else if (info.backendState === 'Running' && info.displayName) {
return E('span', {}, [
E('strong', {}, info.displayName),
' - ',
E('a', {
'href': 'https://login.tailscale.com/admin/machines',
'target': '_blank'
}, _('Manage in Admin Console'))
]);
} else if (!info.isRunning) {
return E('span', { 'style': 'color: #c50;' }, _('Service not running'));
} else {
return E('em', { 'class': 'spinning' }, _('Checking status...'));
}
}
// Status section (full-width with title and pre block)
const StatusOutputValue = form.DummyValue.extend({
__name__: 'StatusOutputValue',
statusId: '',
command: '',
content: '',
sectionTitle: '',
render: function(option_index, section_id, in_table) {
const isLoading = this.content === null;
const hasContent = this.content && this.content.trim();
let preContent;
if (isLoading) {
// Show loading spinner
preContent = E('em', { 'class': 'spinning' }, _('Loading...'));
} else if (hasContent) {
preContent = this.content;
} else {
preContent = _('No output available. Service may not be running.');
}
return E('div', { 'class': 'cbi-section' }, [
E('h3', { 'class': 'cbi-section-title' }, [
this.sectionTitle,
E('span', {
'style': 'font-size: 0.75em; font-weight: normal; color: #666; margin-left: 0.5em;'
}, '(' + this.command + ')')
]),
E('pre', {
'id': this.statusId,
'style': 'background: #f9f9f9; padding: 10px; border: 1px solid #ddd; overflow-x: auto; white-space: pre; font-family: monospace; font-size: 13px;'
}, preContent)
]);
}
});
// Section title (full-width header)
const SectionTitle = form.DummyValue.extend({
__name__: 'SectionTitle',
title: '',
render: function(option_index, section_id, in_table) {
return E('h3', { 'class': 'cbi-section-title' }, this.title);
}
});
const DynamicLogoutTitle = SectionTitle.extend({
__name__: 'DynamicLogoutTitle',
tsInfo: null,
render: function(option_index, section_id, in_table) {
const self = this;
const out = SectionTitle.prototype.render.apply(this, arguments);
const applyVisibility = function(node) {
if (!node)
return node;
node.id = 'ts-logout-title';
if (!self.tsInfo || self.tsInfo.backendState !== 'Running')
node.style.display = 'none';
return node;
};
if (out && typeof out.then === 'function')
return out.then(applyVisibility);
return applyVisibility(out);
}
});
const DynamicLogoutButton = form.Button.extend({
__name__: 'DynamicLogoutButton',
tsInfo: null,
render: function(option_index, section_id, in_table) {
const self = this;
const out = form.Button.prototype.render.apply(this, arguments);
const applyVisibility = function(node) {
if (!node)
return node;
node.id = 'ts-logout-row';
if (!self.tsInfo || self.tsInfo.backendState !== 'Running')
node.style.display = 'none';
return node;
};
if (out && typeof out.then === 'function')
return out.then(applyVisibility);
return applyVisibility(out);
}
});
// Main view
return view.extend({
load: function() {
return getServiceStatus().then(function(isRunning) {
return Promise.all([
Promise.resolve(isRunning),
getTailscaleInfo(isRunning),
getInterfaceSubnets(),
fs.exec('/usr/sbin/tailscale', ['version'])
]);
});
},
render: function(data) {
const isRunning = data[0];
const tsInfo = data[1];
latestTsInfo = tsInfo;
const interfaceSubnets = data[2];
const versionRes = data[3];
const tsVersion = (versionRes && versionRes.code === 0 && versionRes.stdout)
? versionRes.stdout.trim().split('\n')[0]
: null;
let m, s, o;
// Config map (luci-app-tailscale-ng + chained tailscale)
m = new form.Map('luci-app-tailscale-ng', _('Tailscale NG'),
_('Tailscale is a cross-platform and easy to use virtual LAN.'));
// Chain original tailscale config so o.uciconfig='tailscale' options load/save correctly.
m.chain('tailscale');
// Service
s = m.section(form.NamedSection, 'settings', 'settings');
s.anonymous = true;
s.addremove = false;
o = s.option(form.Flag, 'enabled', _('Tailscale Enabled'));
o.description = _('Start Tailscale service on boot.');
o.default = '0';
o.rmempty = false;
o = s.option(ServiceStatusValue, '_service_status', _('Tailscale Status'));
o.isRunning = isRunning;
o.tsInfo = tsInfo;
o = s.option(form.DummyValue, '_tailscale_version', _('Tailscale Version'));
o.renderWidget = function() {
return E('div', { 'style': 'line-height: 30px;' }, tsVersion || _('N/A'));
};
// Tabs
s = m.section(form.NamedSection, 'settings', 'settings');
s.anonymous = true;
s.addremove = false;
s.tab('auth', _('Authentication'));
s.tab('settings', _('Settings'));
s.tab('status', _('Status'));
// Authentication tab
o = s.taboption('auth', SectionTitle, '_auth_title');
o.title = _('Authentication');
o = s.taboption('auth', LoginStatusValue, '_login_status', _('Login Status'));
o.tsInfo = tsInfo;
o = s.taboption('auth', form.Value, 'login_server', _('Login Server'));
o.description = _('Base URL of login (control) server.');
o.placeholder = 'https://controlplane.tailscale.com';
o.rmempty = true;
o = s.taboption('auth', form.Value, 'authkey', _('Pre-authentication Key'));
o.description = _('Set the machine authorization key.') + '<br />' +
'<em>' + _('Use a non-reusable auth key and disable key expiry for trusted machines via the provider admin console.') + '</em>';
o.placeholder = 'tskey-auth-...';
o.rmempty = true;
// Logout (dynamic visibility)
o = s.taboption('auth', DynamicLogoutTitle, '_logout_title');
o.title = _('Logout and Clean');
o.tsInfo = tsInfo;
o = s.taboption('auth', DynamicLogoutButton, '_logout', _('Logout'));
o.inputtitle = _('Logout and Clean');
o.inputstyle = 'negative';
o.description = _('Disconnect from login server, expire current login, and flush local state cache.');
o.tsInfo = tsInfo;
o.onclick = function() {
return handleLogoutAndClean();
};
// Settings tab: General
o = s.taboption('settings', SectionTitle, '_general_title');
o.title = _('General');
o = s.taboption('settings', form.Value, 'port', _('Listen Port'));
o.uciconfig = 'tailscale';
o.description = _('UDP port to listen on for WireGuard and peer-to-peer traffic.');
o.datatype = 'port';
o.default = '41641';
o.rmempty = false;
o = s.taboption('settings', form.Value, 'state_file', _('State File'));
o.uciconfig = 'tailscale';
o.description = _('Path to state file. WARNING: Changing this requires reauthentication.');
o.default = '/etc/tailscale/tailscaled.state';
o.rmempty = false;
o = s.taboption('settings', form.ListValue, 'fw_mode', _('Firewall Mode'));
o.uciconfig = 'tailscale';
o.description = _('Select the firewall backend to use.');
o.value('nftables', 'nftables');
o.value('iptables', 'iptables');
o.default = 'nftables';
o.rmempty = false;
// Settings tab: DNS
o = s.taboption('settings', SectionTitle, '_dns_title');
o.title = _('DNS');
o = s.taboption('settings', form.Flag, 'accept_dns', _('Accept DNS'));
o.description = _('Accept DNS configuration from the control server.');
o.default = '1';
o.rmempty = false;
// Settings tab: Routing
o = s.taboption('settings', SectionTitle, '_routing_title');
o.title = _('Routing');
o = s.taboption('settings', form.Flag, 'advertise_exit_node', _('Advertise Exit Node'));
o.description = _('Offer to be an exit node for outbound internet traffic.');
o.default = '0';
o.rmempty = false;
o = s.taboption('settings', form.Flag, 'accept_routes', _('Accept Subnet Routes'));
o.description = _('Accept subnet routes that other nodes advertise.');
o.default = '0';
o.rmempty = false;
o = s.taboption('settings', form.DynamicList, 'advertise_routes', _('Advertised Routes'));
o.description = _('Subnets to advertise to other Tailscale nodes, e.g. <code>192.168.1.0/24</code>.');
if (interfaceSubnets.length > 0) {
interfaceSubnets.forEach(function(subnet) {
o.value(subnet, subnet);
});
}
o.datatype = 'cidr4';
o.rmempty = true;
o = s.taboption('settings', form.ListValue, 'exit_node', _('Exit Node'));
o.description = _('Select an online machine to use as an exit node.');
o.value('', _('-- None --'));
if (tsInfo.onlineExitNodes.length > 0) {
tsInfo.onlineExitNodes.forEach(function(node) {
o.value(node, node);
});
}
o.default = '';
o.depends('advertise_exit_node', '0');
o.rmempty = true;
// Settings tab: Logging
o = s.taboption('settings', SectionTitle, '_logging_title');
o.title = _('Logging');
o = s.taboption('settings', form.Flag, 'log_stdout', _('StdOut Logging'));
o.uciconfig = 'tailscale';
o.description = _('Logging program activities.');
o.default = '1';
o.rmempty = false;
o = s.taboption('settings', form.Flag, 'log_stderr', _('StdErr Logging'));
o.uciconfig = 'tailscale';
o.description = _('Logging program errors and exceptions.');
o.default = '1';
o.rmempty = false;
// Settings tab: Extra
o = s.taboption('settings', SectionTitle, '_extra_title');
o.title = _('Extra');
o = s.taboption('settings', form.DynamicList, 'flags', _('Additional Flags'));
o.description = String.format(
_('Extra flags in format --flag=value. See %s for available options.'),
'<a href="https://tailscale.com/kb/1241/tailscale-up" target="_blank">' + _('Tailscale documentation') + '</a>'
);
o.rmempty = true;
// Status tab (lazy loaded)
o = s.taboption('status', StatusOutputValue, '_status_main');
o.sectionTitle = _('Tailscale Status');
o.statusId = 'tailscale_status';
o.command = '/usr/sbin/tailscale status';
o.content = null; // Will be loaded lazily
o = s.taboption('status', StatusOutputValue, '_status_ip');
o.sectionTitle = _('Tailscale IP');
o.statusId = 'tailscale_ip';
o.command = '/usr/sbin/tailscale ip';
o.content = null;
o = s.taboption('status', StatusOutputValue, '_status_interface');
o.sectionTitle = _('Tailscale Interface');
o.statusId = 'tailscale_interface';
o.command = '/sbin/ip addr show tailscale0';
o.content = null;
o = s.taboption('status', StatusOutputValue, '_status_netcheck');
o.sectionTitle = _('Tailscale Netcheck');
o.statusId = 'tailscale_netcheck';
o.command = '/usr/sbin/tailscale netcheck';
o.content = null;
o = s.taboption('status', StatusOutputValue, '_status_dns');
o.sectionTitle = _('Tailscale DNS Status');
o.statusId = 'tailscale_dns_status';
o.command = '/usr/sbin/tailscale dns status';
o.content = null;
// Status tab: lazy loading + polling
let statusDataLoaded = false;
async function loadStatusData() {
if (statusDataLoaded) return;
const newData = await getStatusData();
const sections = [
{ id: 'tailscale_status', data: newData.tailscaleStatus },
{ id: 'tailscale_ip', data: newData.tailscaleIp },
{ id: 'tailscale_interface', data: newData.tailscaleInterface },
{ id: 'tailscale_netcheck', data: newData.tailscaleNetcheck },
{ id: 'tailscale_dns_status', data: newData.tailscaleDnsStatus }
];
sections.forEach(function(section) {
const pre = document.getElementById(section.id);
if (pre) {
while (pre.firstChild) pre.removeChild(pre.firstChild);
pre.textContent = section.data && section.data.trim()
? section.data
: _('No output available. Service may not be running.');
}
});
statusDataLoaded = true;
}
// Poll service status and login status
poll.add(async function() {
const running = await getServiceStatus();
const info = await getTailscaleInfo(running);
latestTsInfo = info;
// Update service status display
const statusEl = document.getElementById('ts-service-status');
if (statusEl) {
while (statusEl.firstChild) statusEl.removeChild(statusEl.firstChild);
statusEl.appendChild(renderServiceStatusContent(running, info));
}
// Update login status
const loginEl = document.getElementById('ts-login-status');
if (loginEl) {
while (loginEl.firstChild) loginEl.removeChild(loginEl.firstChild);
loginEl.appendChild(renderLoginStatusContent(info));
}
updateExitNodeOptions(info);
updateLogoutSection(info);
// If status tab is active (has class cbi-tab, not cbi-tab-disabled), refresh its data
const statusTabActive = document.querySelector('li.cbi-tab[data-tab="status"]');
if (statusTabActive) {
statusDataLoaded = false; // Force refresh
loadStatusData();
}
}, 15);
return m.render().then(function(mapNode) {
// Start loading status data immediately in background (fire and forget)
loadStatusData();
updateExitNodeOptions(tsInfo);
updateLogoutSection(tsInfo);
// If login status shows "Checking status...", update it more aggressively
// This handles the case when service just started
if (tsInfo.backendState !== 'Running' && isRunning) {
const quickUpdateLogin = async function() {
const info = await getTailscaleInfo(true);
latestTsInfo = info;
// Keep service status block in sync with quick login updates.
const statusEl = document.getElementById('ts-service-status');
if (statusEl) {
while (statusEl.firstChild) statusEl.removeChild(statusEl.firstChild);
statusEl.appendChild(renderServiceStatusContent(true, info));
}
// Keep login status block in sync with quick login updates.
const loginEl = document.getElementById('ts-login-status');
if (loginEl) {
while (loginEl.firstChild) loginEl.removeChild(loginEl.firstChild);
loginEl.appendChild(renderLoginStatusContent(info));
}
updateExitNodeOptions(info);
updateLogoutSection(info);
// When login completes, force-refresh full Status tab data once.
if (info.backendState === 'Running') {
statusDataLoaded = false;
loadStatusData();
return;
}
// Stop aggressive polling once auth URL is available for user action.
if (info.backendState === 'NeedsLogin' && info.authURL) {
return;
}
// Still not ready, try again in 2 seconds.
setTimeout(quickUpdateLogin, 2000);
};
setTimeout(quickUpdateLogin, 2000);
}
// Add click listener for Status tab to trigger load if not yet loaded
const statusTabLink = mapNode.querySelector('.cbi-tabmenu li:last-child a, .cbi-tabmenu li[data-tab="status"] a');
if (statusTabLink) {
statusTabLink.addEventListener('click', function() {
setTimeout(loadStatusData, 100); // Will return immediately if already loaded
});
}
// Also try via tab container click
mapNode.addEventListener('click', function(ev) {
const tabItem = ev.target.closest('.cbi-tabmenu li');
if (tabItem && tabItem.textContent.includes('Status')) {
setTimeout(loadStatusData, 100);
}
if (tabItem && tabItem.textContent.includes('Settings')) {
setTimeout(function() {
updateExitNodeOptions(latestTsInfo || tsInfo);
}, 100);
}
});
return mapNode;
});
}
});
+56
View File
@@ -0,0 +1,56 @@
#!/bin/sh
set -eu
PKG_NAME="luci-app-tailscale-ng"
REPO="vad-b/luci-app-tailscale-ng"
API_URL="https://api.github.com/repos/${REPO}/releases/latest"
log() {
echo "[install.sh] $*"
}
fail() {
echo "[install.sh] ERROR: $*" >&2
exit 1
}
command -v wget >/dev/null 2>&1 || fail "wget is required but not found"
if command -v apk >/dev/null 2>&1; then
PKG_EXT="apk"
PKG_MGR="apk"
elif command -v opkg >/dev/null 2>&1; then
PKG_EXT="ipk"
PKG_MGR="opkg"
else
fail "no supported package manager found (apk/opkg)"
fi
TMP_JSON="/tmp/${PKG_NAME}-latest.json"
log "Detected package manager: ${PKG_MGR} (expecting .${PKG_EXT})"
log "Fetching latest release metadata"
wget -qO "${TMP_JSON}" "${API_URL}" || fail "failed to fetch release metadata"
DOWNLOAD_URL="$(grep -oE '"browser_download_url":[[:space:]]*"[^"]+"' "${TMP_JSON}" \
| sed -E 's/.*"([^"]+)"/\1/' \
| grep -E "/${PKG_NAME}[^/]*\.${PKG_EXT}$" \
| head -n 1 || true)"
[ -n "${DOWNLOAD_URL}" ] || fail "no .${PKG_EXT} asset found in latest release"
PKG_FILE="/tmp/$(basename "${DOWNLOAD_URL}")"
log "Downloading ${PKG_FILE}"
wget -qO "${PKG_FILE}" "${DOWNLOAD_URL}" || fail "failed to download package"
log "Installing ${PKG_FILE}"
if [ "${PKG_MGR}" = "apk" ]; then
apk add --allow-untrusted --upgrade "${PKG_FILE}" || fail "apk install failed"
else
opkg install "${PKG_FILE}" || fail "opkg install failed"
fi
rm -f "${TMP_JSON}"
log "Done"
@@ -0,0 +1,29 @@
config settings 'settings'
# Enable Tailscale service on boot
option enabled '0'
# Custom login/control server URL; set only for non-default control like Headscale.
#option login_server 'https://headscale.example.com'
# Pre-authentication key
#option authkey 'tskey-auth-xxxxxxxxxxxxxxxx'
# Accept DNS configuration from the control server
option accept_dns '0'
# Accept subnet routes that other nodes advertise
option accept_routes '0'
# Offer to be an exit node for outbound internet traffic
option advertise_exit_node '0'
# Use this machine as an exit node (Tailscale IP or node name)
#option exit_node 'openwrt-server'
# Subnets to advertise to other Tailscale nodes. Add one "list advertise_routes" line per subnet.
#list advertise_routes '192.168.1.0/24'
#list advertise_routes '192.168.50.0/24'
# Extra tailscale up flags not exposed in LuCI yet. Add one "list flags" line per flag.
#list flags '--hostname=openwrt-router'
#list flags '--ssh=true'
@@ -0,0 +1,14 @@
#!/bin/sh
# Copyright (C) 2026 vad-b
# SPDX-License-Identifier: GPL-3.0-only
[ "$ACTION" = ifup -o "$ACTION" = ifupdate ] || exit 0
[ "$ACTION" = ifupdate -a -z "$IFUPDATE_ADDRESSES" -a -z "$IFUPDATE_DATA" ] && exit 0
tailscale_enable="$(uci -q get luci-app-tailscale-ng.settings.enabled)"
[ "$tailscale_enable" = "1" ] || exit 0
sleep 10
/etc/init.d/luci-app-tailscale-ng start >/dev/null 2>&1 &
+120
View File
@@ -0,0 +1,120 @@
#!/bin/sh /etc/rc.common
# Copyright (C) 2026 vad-b
# SPDX-License-Identifier: GPL-3.0-only
# This init script manages 'tailscale up' configuration options.
# It does NOT manage the tailscaled daemon itself - that's handled by
# the standard /etc/init.d/tailscale from the tailscale package.
#
# This script reads UCI options from /etc/config/luci-app-tailscale-ng
# and applies them via 'tailscale up --reset'.
USE_PROCD=1
START=95
service_triggers() {
procd_add_reload_trigger "tailscale"
procd_add_reload_trigger "luci-app-tailscale-ng"
}
start_service() {
logger -t "luci-app-tailscale-ng" "start_service"
local enabled enabled_status ts_service_status
local login_server authkey
local accept_dns accept_routes advertise_exit_node
local exit_node
local routes_csv
routes_csv=""
append_route_csv() {
local v="$1"
[ -n "$v" ] || return 0
case "$v" in
*[[:space:]]*) logger -t "luci-app-tailscale-ng" "ignoring invalid advertised route (must not contain spaces): [$v]"; return 0 ;;
esac
append routes_csv "$v" ","
}
append_extra_flag() {
local v="$1"
[ -n "$v" ] || return 0
case "$v" in
--*) ;;
*) logger -t "luci-app-tailscale-ng" "ignoring invalid extra flag (must start with --): [$v]"; return 0 ;;
esac
case "$v" in
*[[:space:]]*) logger -t "luci-app-tailscale-ng" "ignoring invalid extra flag (must not contain spaces): [$v]"; return 0 ;;
esac
procd_append_param command "$v"
}
config_load "luci-app-tailscale-ng"
config_get_bool enabled "settings" "enabled" 0
# Handle enabled/disabled state
[ "$enabled" -eq 1 ] && enabled_status="true" || enabled_status="false"
logger -t "luci-app-tailscale-ng" "tailscale service enabled: [$enabled_status]"
if [ "$enabled" -eq 0 ]; then
logger -t "luci-app-tailscale-ng" "tailscale service disabled, stopping tailscale"
/etc/init.d/tailscale stop
return 0
fi
# Start/reload tailscale service if status is not "running"
ts_service_status="$(/sbin/service tailscale status)"
logger -t "luci-app-tailscale-ng" "tailscale service status: [$ts_service_status]"
if [ "$ts_service_status" != "running" ]; then
logger -t "luci-app-tailscale-ng" "tailscale service is not running, reloading tailscale"
/etc/init.d/tailscale reload
fi
# Read configuration options
config_get login_server "settings" "login_server" ""
config_get authkey "settings" "authkey" ""
config_get_bool accept_dns "settings" "accept_dns" 1
config_get_bool accept_routes "settings" "accept_routes" 0
config_get_bool advertise_exit_node "settings" "advertise_exit_node" 0
config_get exit_node "settings" "exit_node" ""
config_list_foreach "settings" "advertise_routes" append_route_csv
#
# Build and execute tailscale up via procd-managed instance
#
procd_open_instance up
procd_set_param stdout 1
procd_set_param stderr 1
procd_set_param command /usr/sbin/tailscale up --reset
[ -n "$login_server" ] && procd_append_param command "--login-server=$login_server"
[ -n "$authkey" ] && procd_append_param command "--authkey=$authkey"
[ "$accept_dns" = "0" ] && procd_append_param command "--accept-dns=false"
[ "$accept_routes" = "1" ] && procd_append_param command "--accept-routes=true"
[ "$advertise_exit_node" = "1" ] && procd_append_param command "--advertise-exit-node"
[ -n "$exit_node" ] && {
procd_append_param command "--exit-node=$exit_node"
procd_append_param command "--exit-node-allow-lan-access=true"
}
[ -n "$routes_csv" ] && procd_append_param command "--advertise-routes=$routes_csv"
config_list_foreach "settings" "flags" append_extra_flag
logger -t "luci-app-tailscale-ng" "applying configuration via tailscale up"
procd_close_instance
return 0
}
stop_service() {
logger -t "luci-app-tailscale-ng" "stopping tailscale"
/etc/init.d/tailscale stop
}
reload_service() {
restart
}
@@ -0,0 +1,8 @@
#!/bin/sh
# Enable service on boot and start it once to register procd triggers
/etc/init.d/luci-app-tailscale-ng enable
/etc/init.d/luci-app-tailscale-ng start >/dev/null 2>&1 || true
rm -f /tmp/luci-indexcache
exit 0
@@ -0,0 +1,14 @@
{
"admin/vpn/tailscale-ng": {
"title": "Tailscale NG",
"order": 90,
"action": {
"type": "view",
"path": "tailscale-ng/index"
},
"depends": {
"acl": [ "luci-app-tailscale-ng" ],
"uci": { "tailscale": true }
}
}
}
@@ -0,0 +1,23 @@
{
"luci-app-tailscale-ng": {
"description": "Grant access to Tailscale NG configuration",
"read": {
"file": {
"/sys/class/net/tailscale0/*": [ "read" ],
"/sbin/ip": [ "exec" ],
"/usr/sbin/tailscale": [ "exec" ]
},
"ubus": {
"luci": [ "getInitList", "setInitAction" ],
"service": [ "list" ]
},
"uci": [ "tailscale", "luci-app-tailscale-ng" ]
},
"write": {
"ubus": {
"luci": [ "setInitAction" ]
},
"uci": [ "tailscale", "luci-app-tailscale-ng" ]
}
}
}
@@ -1,210 +0,0 @@
# Final Review Fix Report
## Scope completed
All six final-review corrections were implemented as one fix wave:
1. Added an nftables table-generation guard before handle caching, made its deletion the first operation in the atomic reconciliation batch, and covered success, failure, signal, cleanup, and deterministic table-replacement paths.
2. Distinguished a verified absent UCI `enabled` option from UCI section/read failures.
3. Added `nft -j list tables` inventory checks so verified `inet fw4` absence remains non-fatal while inventory, table-read, and malformed-JSON failures remain errors.
4. Reordered cleanup to remove nftables rules before the hook, retained the hook when nftables cleanup failed, and made disabled status report managed residue.
5. Bumped `PKG_VERSION` to `1.2.8`.
6. Made status require the canonical managed hook body and exact structured nftables rule expressions while allowing dynamic counter values.
A second independent whole-branch review found five additional reconciliation issues, which were fixed before deployment:
1. Chain reads now require structurally valid nftables JSON with exactly one matching chain object and correctly scoped rules with numeric handles.
2. The command used by the official OpenClash hook now performs full cleanup when the feature is disabled, including removal of the managed hook.
3. A failed or interrupted independent generation-guard deletion remains tracked so the EXIT trap can retry it.
4. Status validates UCI and disabled managed artifacts before treating an absent `inet fw4` table as unsupported.
5. If any target OpenClash chain is absent, reconciliation removes any surviving package-owned partial rule set and waits without installing a subset.
A final focused review found one remaining apply-time race: a target chain could disappear after the initial complete table snapshot but before its individual chain read. That path now removes package-owned rules from all surviving chains instead of returning with a partial rule set.
## Strict TDD evidence
Temporary test paths in two failure messages are normalized as `<tmp>`; all other output is verbatim.
### Correction 1: table-generation guard
- RED command: `sh tests/tailscale_openclash_bypass_test.sh`
- RED result: exit 1, `FAIL: apply must create a named counter before caching owned handles`
- GREEN command: `sh tests/tailscale_openclash_bypass_test.sh`
- GREEN result: exit 0, `tailscale OpenClash bypass tests passed`
### Correction 2: UCI absence versus failure
- RED command: `sh tests/tailscale_openclash_bypass_test.sh`
- RED result: exit 1, `FAIL: <tmp>/uci.log expected 1 exact occurrences of -q show tailscale_openclash.settings, got 0`
- GREEN command: `sh tests/tailscale_openclash_bypass_test.sh`
- GREEN result: exit 0, `tailscale OpenClash bypass tests passed`
### Correction 3: nftables table inventory
- RED command: `sh tests/tailscale_openclash_bypass_test.sh`
- RED result: exit 1, `FAIL: <tmp>/nft.calls expected 1 occurrences of -j list tables, got 0`
- GREEN command: `sh tests/tailscale_openclash_bypass_test.sh`
- GREEN result: exit 0, `tailscale OpenClash bypass tests passed`
### Correction 4: retryable cleanup and disabled residue
- RED command: `sh tests/tailscale_openclash_bypass_test.sh`
- RED result: exit 1, `FAIL: false alias 0 did not report disabled`
- GREEN command: `sh tests/tailscale_openclash_bypass_test.sh`
- GREEN result: exit 0, `tailscale OpenClash bypass tests passed`
### Correction 5: release version
- RED command: `sh tests/package_release_test.sh`
- RED result: exit 1, `FAIL: Makefile should contain: PKG_VERSION:=1.2.8`
- GREEN command: `sh tests/package_release_test.sh`
- GREEN result: exit 0, `package release tests passed`
### Correction 6: canonical status validation
- RED command: `sh -n tests/tailscale_openclash_bypass_test.sh && sh tests/tailscale_openclash_bypass_test.sh`
- RED result: exit 1, `FAIL: status accepted a noncanonical managed hook body`
- GREEN command: `sh -n root/usr/sbin/tailscale_openclash_bypass && sh tests/tailscale_openclash_bypass_test.sh`
- GREEN result: exit 0, `tailscale OpenClash bypass tests passed`
### Second review: strict chain JSON validation
- RED command: `sh tests/tailscale_openclash_bypass_test.sh`
- RED result: exit 1, `FAIL: apply accepted structurally invalid chain JSON mode empty`
- GREEN command: `sh tests/tailscale_openclash_bypass_test.sh`
- GREEN result: exit 0, `tailscale OpenClash bypass tests passed`
- Compatibility evidence: a read-only query against the office OpenWrt 24.10.5 / nftables 1.1.1 returned the expected `metainfo`, matching `chain`, and scoped `rule` objects with numeric handles. The fake nft fixture now includes that real top-level structure.
### Second review: retryable hook cleanup and generation guard
- Added regression coverage for the actual official hook path (`apply` while disabled), independent guard-deletion failure, and termination during guard deletion.
- GREEN result: `tailscale OpenClash bypass tests passed`
### Second review: status ordering and partial-rule cleanup
- Added regression coverage for invalid UCI with no fw4 table, disabled hook residue with no fw4 table, clean disabled state with no fw4 table, and a missing target chain with surviving partial owned rules.
- GREEN result: `tailscale OpenClash bypass tests passed`
### Final review: apply-time chain disappearance
- RED command: `sh tests/tailscale_openclash_bypass_test.sh`
- RED result: exit 1, `FAIL: apply did not clean surviving owned rules after a target chain disappeared during inspection`
- GREEN command: `sh -n root/usr/sbin/tailscale_openclash_bypass && sh -n tests/tailscale_openclash_bypass_test.sh && sh tests/tailscale_openclash_bypass_test.sh`
- GREEN result: exit 0, `tailscale OpenClash bypass tests passed`
### Office preflight: OpenWrt applet compatibility
- A read-only preflight on the office OpenWrt 24.10.5 image confirmed that neither `stat` nor `od` is installed or provided as a BusyBox applet.
- RED command: `sh tests/tailscale_openclash_bypass_test.sh`
- RED result: exit 1 after fake target-image `stat` and `od` commands returned 127.
- Replaced hook metadata reads with POSIX `ls -ldn | awk` parsing. The parser preserves owner/group, ordinary mode bits, and setuid/setgid/sticky bits.
- Replaced single-byte hexadecimal inspection with `dd | grep` newline detection, using commands present on the target image.
- Added regression coverage that hides `stat` and `od` from the helper and verifies numeric restoration of mode `6750` after atomic replacement.
- GREEN command: `sh tests/tailscale_openclash_bypass_test.sh`
- GREEN result: exit 0, `tailscale OpenClash bypass tests passed`
### Compatibility re-review: byte-read errors and special modes
- A fresh independent review found that a POSIX pipeline exposed only the final `grep` status, so an underlying `dd` failure could be mistaken for an ordinary non-newline byte at the optional suffix boundary.
- RED command: `sh tests/tailscale_openclash_bypass_test.sh`
- RED result: exit 1, `FAIL: reconcile-hook must fail when the optional-newline probe cannot be read`
- The byte probe now appends and parses the `dd` status without losing a leading newline to command substitution. It returns three distinct states: newline, non-newline/EOF, and read error.
- Both optional-newline call sites now abort atomic replacement on a read error.
- Added special-mode reconciliation and cleanup coverage for `4640`, `1750`, `1751`, and `7777`, in addition to the existing `6750` check. This covers uppercase and lowercase setuid/setgid/sticky renderings (`S/s/T/t`).
- GREEN command: `sh tests/tailscale_openclash_bypass_test.sh`
- GREEN result: exit 0, `tailscale OpenClash bypass tests passed`
The complete 11-file shell suite and 4-file JavaScript suite also passed after the compatibility correction GREEN.
## Final verification
Focused checks:
```sh
sh -n root/usr/sbin/tailscale_openclash_bypass && \
sh -n tests/tailscale_openclash_bypass_test.sh && \
sh tests/tailscale_openclash_bypass_test.sh
sh -n tests/package_release_test.sh && sh tests/package_release_test.sh
```
Result: both passed.
Complete shell suite:
```sh
set -eu
count=0
for test_file in tests/*_test.sh; do
sh "$test_file"
count=$((count + 1))
done
printf 'shell test files passed: %s\n' "$count"
```
Latest result after the target-image compatibility correction: `shell test files passed: 11`.
Complete JavaScript suite:
```sh
set -eu
count=0
for test_file in tests/*_test.js; do
node "$test_file"
count=$((count + 1))
done
printf 'JavaScript test files passed: %s\n' "$count"
```
Latest result after the target-image compatibility correction: `JavaScript test files passed: 4`.
Static gates:
```sh
for file in .github/scripts/*.sh; do bash -n "$file"; done
for file in root/etc/init.d/tailscale root/etc/init.d/tailscale-openclash-bypass root/etc/hotplug.d/iface/* root/etc/uci-defaults/* root/usr/libexec/rpcd/* root/usr/sbin/tailscale_* tests/*_test.sh; do sh -n "$file"; done
for file in htdocs/luci-static/resources/view/tailscale/*.js tests/*_test.js; do node --check "$file"; done
for file in root/usr/share/luci/menu.d/*.json root/usr/share/rpcd/acl.d/*.json; do jq -e . "$file" >/dev/null; done
for file in po/templates/*.pot po/*/*.po; do msgfmt --check-format -o /dev/null "$file"; done
git diff --check
```
Latest results: 23 shell syntax files, 8 JavaScript syntax files, 2 JSON files, 3 translation files, both the whole-branch and working-tree `git diff --check` checks passed.
Ownership-isolation searches returned no matches:
```sh
rg -n '/etc/config/firewall|uci (set|delete|commit) firewall|/etc/init.d/firewall|fw4 (reload|restart)' root/usr/sbin/tailscale_openclash_bypass root/etc/init.d/tailscale-openclash-bypass
rg -n 'tailscale_openclash_bypass|openclash_custom_firewall_rules' root/usr/sbin/tailscale_helper root/etc/init.d/tailscale
rg -n '/etc/init.d/openclash (start|stop|restart|reload|enable|disable)' root/usr/sbin/tailscale_openclash_bypass root/etc/init.d/tailscale-openclash-bypass
```
No Docker, MkDocs, router write, service reload/restart, GitHub, or production mutation command was used. Read-only SSH checks queried the office OpenWrt's nftables JSON and available applets for compatibility evidence; `hzsls-openwrt` was not accessed.
## Files changed
- `Makefile`
- `root/usr/sbin/tailscale_openclash_bypass`
- `tests/package_release_test.sh`
- `tests/tailscale_openclash_bypass_test.sh`
- `.superpowers/sdd/final-review-fix-report.md`
The pre-existing modifications to task-1 through task-4 reports were preserved byte-for-byte relative to their initial working-tree diff and excluded from staging.
## Self-review
- Reviewed the complete production and test diff against all six required corrections.
- Confirmed the generation-replacement fake preserves unrelated rules that reuse stale handles.
- Confirmed guard cleanup paths cover success, batch failure, signal exit, no-op cleanup, and table replacement.
- Confirmed status rejects noncanonical hooks, altered/overbroad/incomplete/wrong-chain rules, duplicate rules, and unexpected package-owned rules while accepting changing counter values.
- Confirmed cleanup failure leaves the hook retryable and disabled status reports both hook and rule residue.
- Confirmed structurally invalid chain JSON cannot trigger apply or cleanup mutation.
- Confirmed a missing target chain removes surviving partial owned rules and never installs an incomplete set.
- Confirmed a target chain disappearing after the initial snapshot also removes the surviving partial owned rules and reports a residue-free waiting state.
- Confirmed OpenWrt 24.10.5 / nftables 1.1.1 real chain JSON satisfies the strict validator.
- Confirmed only the five files listed above belong to this fix wave.
## Remaining concerns
Office deployment and device-level behavior verification remain pending execution of the approved backup/change/verification/rollback plan. No deployment write has started yet.
The final independent re-review of commit `94eb302` reported: `未发现可操作问题,READY`.
@@ -1,36 +0,0 @@
# Task 1 Report: Tailscale peer observability helper
## Completed
- Added `tests/tailscale_peer_probe_test.sh` with RED-first coverage for:
- successful direct peer latency output
- successful DERP peer relay + latency
- unknown format handling
- ping failure path
- invalid peer input
- missing argument usage response
- Added `root/usr/sbin/tailscale_peer_probe` implementation.
- Extended `tests/package_release_test.sh` assertions for:
- helper file existence
- ACL references to `tailscale_peer_probe`
- "Peer probe" comment marker in helper
## Test results
- `sh tests/tailscale_peer_probe_test.sh` -> **pass**
- `sh tests/package_release_test.sh` -> **fail** at `assert_contains "tailscale_peer_probe" root/usr/share/rpcd/acl.d/luci-app-tailscale.json`
## Commit
- `08259ed` (`Add Tailscale peer probe helper`)
## Remaining concern
- The package release test failure is expected for Task 1 because ACL/menu registration is not yet implemented in later tasks.
## Review Findings Fix (Task 1 Follow-up)
- Removed the premature ACL assertion from `tests/package_release_test.sh` so Task 1 static tests no longer validate ACL registration.
- Expanded `json_escape` in `root/usr/sbin/tailscale_peer_probe` to escape control characters in raw output: backslash, double quote, tab, carriage return, and newline.
- Extended `tests/tailscale_peer_probe_test.sh` with a multiline control-peer output case covering escaped `\"`, `\\`, `\\t`, `\\r`, and `\\n`, and an explicit single-line JSON output assertion.
### Verification commands
- `sh tests/tailscale_peer_probe_test.sh`
- Output: `tailscale_peer_probe tests passed`
- `sh tests/package_release_test.sh`
- Output: `package release tests passed`
@@ -1,35 +0,0 @@
# Task 2 Report: Menu and ACL
Status: completed
Date (UTC): 2026-07-13 18:45:31 UTC
## Implemented
- Added menu route entry assertions in `tests/package_release_test.sh`:
- `admin/vpn/tailscale/peers`
- `"title": "Peers"`
- `"path": "tailscale/peers"`
- order assertions before interface and log routes.
- Added ACL assertion in `tests/package_release_test.sh` for:
- `"/usr/sbin/tailscale_peer_probe": [ "exec" ]`
- Updated `root/usr/share/luci/menu.d/luci-app-tailscale.json`:
- inserted `admin/vpn/tailscale/peers` with title `Peers`, order `25`, and view path `tailscale/peers`.
- Updated `root/usr/share/rpcd/acl.d/luci-app-tailscale.json`:
- added `"/usr/sbin/tailscale_peer_probe": [ "exec" ]` under `"read" -> "file"`.
## Verification
- `sh tests/package_release_test.sh`
- before implementation: failed at missing `admin/vpn/tailscale/peers` assertion
- after implementation: `package release tests passed`
## Follow-up Fix (Task 2.i)
- Added localization entry for menu title `Peers` to `po/templates/tailscale.pot` with references to `root/usr/share/luci/menu.d/luci-app-tailscale.json:30`.
- Added `msgstr "对端列表"` for Simplified Chinese in `po/zh_Hans/tailscale.po`.
- Added `msgstr "對端列表"` for Traditional Chinese in `po/zh_Hant/tailscale.po`.
- Added focused assertions in `tests/package_release_test.sh` for:
- `msgid "Peers"` in `po/templates/tailscale.pot`, `po/zh_Hans/tailscale.po`, `po/zh_Hant/tailscale.po`
- `msgstr "对端列表"` in `po/zh_Hans/tailscale.po`
- `msgstr "對端列表"` in `po/zh_Hant/tailscale.po`
- Ran:
- `sh tests/package_release_test.sh`
- Result: `package release tests passed`
@@ -1,38 +0,0 @@
# Task 3 Report: Tailscale Peers LuCI View
## Summary
- Added the read-only LuCI peers page at `admin/vpn/tailscale/peers`.
- The page loads `tailscale status --json`, lists all peers, supports `all`, `online`, and `advertising subnets` filters, and adds a per-peer `Probe` action that calls `/usr/sbin/tailscale_peer_probe`.
- Probe results render the path label as `Direct`, `DERP`, `Failed`, or `Unknown` with the helper summary.
## Changed Files
- `htdocs/luci-static/resources/view/tailscale/peers.js`
- `tests/package_release_test.sh`
## RED / GREEN
- RED: `sh tests/package_release_test.sh`
- Result: `FAIL: missing required package file: htdocs/luci-static/resources/view/tailscale/peers.js`
- GREEN: `node --check htdocs/luci-static/resources/view/tailscale/peers.js`
- Result: exit `0`
- GREEN: `sh tests/package_release_test.sh`
- Result: `package release tests passed`
## Self-Review
- The page is read-only: no save/apply/reset handlers and no reload actions.
- The helper call is per-peer only; there is no bulk probe control.
- Status parsing reads all peers from `tailscale status --json`, not just subnet advertisers.
- Filter state, probe state, and result rendering are kept local to the view and survive polling refreshes.
## Concerns
- None.
## Task 3 Fix (2026-07-14)
- `loadPeerState()` now checks `res.code` after `tailscale status --json` execution; when `res.code !== 0`, it returns `{ ok: false, peers: [], error: <message> }` without parsing status.
- `refreshPeers()` now clears stale rows on failed refresh by setting `state.peers = []` and `state.error = next.error` when `next.ok === false`.
- Behavior remains read-only (no reload/restart/network side effects introduced).
### Verification
- Command: `node --check htdocs/luci-static/resources/view/tailscale/peers.js`
- Result: syntax check passed (exit 0).
- Command: `sh tests/package_release_test.sh`
- Result: `package release tests passed`.
@@ -1,37 +0,0 @@
# Task 4 Report
## Summary
Implemented the Tailscale peers translation set for the peer observability page.
Added new POT and PO entries for the actual strings used by `peers.js`, and extended
`tests/package_release_test.sh` to assert the key peer-page translations.
## Changed Files
- `/Users/jearton/projects/litata/luci-app-tailscale/po/templates/tailscale.pot`
- `/Users/jearton/projects/litata/luci-app-tailscale/po/zh_Hans/tailscale.po`
- `/Users/jearton/projects/litata/luci-app-tailscale/po/zh_Hant/tailscale.po`
- `/Users/jearton/projects/litata/luci-app-tailscale/tests/package_release_test.sh`
## Commands And Results
- `sh tests/package_release_test.sh`
- First run: failed on missing `Tailscale Peers` POT entry, which confirmed the new assertions were active.
- Final run: passed with `package release tests passed`.
- Duplicate msgid sanity check across the three translation files:
- Passed with `no duplicate msgid entries found`.
- `git diff --check -- po/templates/tailscale.pot po/zh_Hans/tailscale.po po/zh_Hant/tailscale.po tests/package_release_test.sh`
- Passed with no whitespace or patch-format issues.
## Self-Review
- Verified that the new peer-page strings are present in the POT and both Chinese PO files.
- Kept existing shared msgids such as `Peers`, `Online`, `Offline`, and `Status` as single catalog entries instead of adding duplicates.
- Confirmed the package release test covers the new peer page strings and still passes.
## Concerns
- The worktree already contained unrelated dirty files outside the allowed write scope:
- `/Users/jearton/projects/litata/luci-app-tailscale/root/etc/init.d/tailscale`
- `/Users/jearton/projects/litata/luci-app-tailscale/tests/tailscale_init_adguard_lifecycle_test.sh`
- Those files were intentionally left untouched.
+4 -44
View File
@@ -5,52 +5,12 @@
include $(TOPDIR)/rules.mk
LUCI_TITLE:=LuCI for Tailscale
LUCI_DEPENDS:=+tailscale +jshn +curl +jq +flock
LUCI_DEPENDS:=+tailscale
LUCI_PKGARCH:=all
PKG_VERSION:=1.2.17
PKG_RELEASE:=6
# 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/conffiles
/etc/config/tailscale
/etc/config/tailscale_openclash
/etc/config/tailscale_policy_routing
endef
define Package/luci-app-tailscale/prerm
#!/bin/sh
[ -n "$${IPKG_INSTROOT}" ] && exit 0
if [ -x /usr/sbin/tailscale_policy_routing ]; then
/usr/sbin/tailscale_policy_routing cleanup >/dev/null 2>&1 || true
fi
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 [ -x /usr/sbin/tailscale_openclash_bypass ]; then
/usr/sbin/tailscale_openclash_bypass cleanup >/dev/null 2>&1 || true
fi
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 [ -x /etc/init.d/tailscale ]; then
/etc/init.d/tailscale stop >/dev/null 2>&1 || true
fi
exit 0
endef
PKG_VERSION:=1.3.0
PKG_RELEASE:=7
include $(TOPDIR)/feeds/luci/luci.mk
# call BuildPackage - OpenWrt buildroot signature
# call BuildPackage - OpenWrt buildroot signature
+26 -126
View File
@@ -2,11 +2,25 @@
Tailscale is a zero config VPN for building secure networks.
[![GitHub release (latest by date)](https://img.shields.io/github/v/release/asvow/luci-app-tailscale?style=flat-square)](https://github.com/asvow/luci-app-tailscale/releases)
[![GitHub stars](https://img.shields.io/github/stars/asvow/luci-app-tailscale?style=flat-square)](https://github.com/asvow/luci-app-tailscale/stargazers)
[![GitHub forks](https://img.shields.io/github/forks/asvow/luci-app-tailscale?style=flat-square)](https://github.com/asvow/luci-app-tailscale/network/members)
[![License](https://img.shields.io/github/license/asvow/luci-app-tailscale?style=flat-square)](LICENSE)
[![GitHub All Releases](https://img.shields.io/github/downloads/asvow/luci-app-tailscale/total?style=flat-square)](https://github.com/asvow/luci-app-tailscale/releases)
[![GitHub release (latest by date)](https://img.shields.io/github/v/release/Kingwong55/luci-app-tailscale?style=flat-square)](https://github.com/Kingwong55/luci-app-tailscale/releases)
[![GitHub stars](https://img.shields.io/github/stars/Kingwong55/luci-app-tailscale?style=flat-square)](https://github.com/Kingwong55/luci-app-tailscale/stargazers)
[![License](https://img.shields.io/github/license/Kingwong55/luci-app-tailscale?style=flat-square)](LICENSE)
[![GitHub All Releases](https://img.shields.io/github/downloads/Kingwong55/luci-app-tailscale/total?style=flat-square)](https://github.com/Kingwong55/luci-app-tailscale/releases)
> Fork from [asvow/luci-app-tailscale](https://github.com/asvow/luci-app-tailscale),基于 v1.2.6 定制开发。
## ✨ 新增功能
- **一键更新 Tailscale 内核** — 在 LuCI 设置页面直接检查并更新 Tailscale 二进制文件
- **自动版本检测** — 页面加载时自动对比当前版本与最新版本
- **智能按钮状态** — 已是最新版本时按钮自动禁用
## 🐛 修复
- 修复接口信息页面 `ip` 命令权限错误(添加 `/usr/bin/ip` 兜底路径)
- 更新 rpcd ACL 权限配置
--------------
## How to build
@@ -21,7 +35,7 @@ Tailscale is a zero config VPN for building secure networks.
*2. get luci-app-tailscale source & building*
```shell
git clone https://github.com/asvow/luci-app-tailscale package/luci-app-tailscale
git clone https://github.com/Kingwong55/luci-app-tailscale package/luci-app-tailscale
make menuconfig # choose LUCI -> Applications -> luci-app-tailscale
make package/luci-app-tailscale/compile V=s # luci-app-tailscale
```
@@ -30,131 +44,17 @@ Tailscale is a zero config VPN for building secure networks.
## How to install prebuilt packages
- Upload the prebuilt ipk or apk package to the /tmp directory of OpenWrt
- Upload the prebuilt ipk package to the /tmp directory of OpenWrt
- Login OpenWrt terminal (SSH)
Install the main package, then optionally install either the Simplified Chinese or Traditional Chinese language package.
### opkg package manager
```shell
opkg update
opkg install /tmp/luci-app-tailscale_*.ipk
# Optional Simplified Chinese translation
opkg install /tmp/luci-i18n-tailscale-zh-cn_*.ipk
# Or optional Traditional Chinese translation
opkg install /tmp/luci-i18n-tailscale-zh-tw_*.ipk
```
### apk package manager
```shell
apk update
apk add --allow-untrusted /tmp/luci-app-tailscale-*.apk
# Optional Simplified Chinese translation
apk add --allow-untrusted /tmp/luci-i18n-tailscale-zh-cn-*.apk
# Or optional Traditional Chinese translation
apk add --allow-untrusted /tmp/luci-i18n-tailscale-zh-tw-*.apk
```
--------------
## OpenClash Bypass
- Supports firewall4/nftables only.
- Enabled by default when the packaged setting is present; it is a no-op when OpenClash is absent.
- Manages one delimited block in `/etc/openclash/custom/openclash_custom_firewall_rules.sh`.
- The managed block invokes `/usr/sbin/tailscale_openclash_bypass apply` through OpenClash's official custom firewall hook.
- Manages exactly four comment-owned rules in OpenClash's nftables chains.
- Does not modify `/etc/config/firewall`, reload firewall4, or start/restart OpenClash.
- WAN direct remains a separate feature that manages only its own firewall UCI rules.
- Status states: `active`, `waiting`, `disabled`, `absent`, `unsupported`, and `error`.
- Cleanup removes only the managed hook block and the four `luci-app-tailscale:` rules.
- Package installation uses the standard LuCI/OpenWrt post-install lifecycle; the packaged init script performs one locked `sync`.
Rollback:
```sh
uci set tailscale_openclash.settings.enabled='0'
uci commit tailscale_openclash
/etc/init.d/tailscale-openclash-bypass reload
/usr/sbin/tailscale_openclash_bypass status
```
--------------
## Auth Key Lifecycle
- An auth key is used only for first-time enrollment or when a new value is
explicitly entered in LuCI to re-enroll the device.
- After the connection obtains a Tailscale address, the saved auth key is
removed from every protected credential version. The persistent
`tailscaled.state` identity is kept and is used for normal restarts.
- An auth key is retained when enrollment fails so the configuration can be
corrected and retried.
- Auth keys migrated from an older package are ignored when an existing
`tailscaled.state` identity is present, preventing a reboot from attempting
an unintended registration.
--------------
## mwan3 Policy Routing Compatibility
- Disabled by default; enable it only when mwan3 fwmark rules take precedence
over Tailscale table `52` and LAN clients cannot reach Tailnet routes.
- Creates an app-owned IPv4 `priority 1000 lookup 52` rule before mwan3's
fwmark rules. Destinations absent from table `52` continue to mwan3.
- Refuses to activate if table `52` has a default route, such as while using a
Tailscale exit node, so ordinary internet traffic is not diverted from mwan3.
- Uses only `network` UCI, `ip rule`, and an interface-event self-heal. It does
not modify firewall UCI, reload firewall4, or manage mwan3/OpenClash.
- Validate from a real LAN client or with an equivalent mwan3 fwmark; an
unmarked `ip route get` on the router is not a sufficient test.
Rollback:
```sh
uci set tailscale_policy_routing.settings.enabled='0'
uci commit tailscale_policy_routing
/etc/init.d/tailscale-policy-routing reload
/usr/sbin/tailscale_policy_routing status
```
--------------
## AdGuard DNS Auto Switch
- Disabled by default.
- Intended for OpenWrt setups where local AdGuard Home owns LAN DNS port 53.
- LAN DHCP should advertise the router LAN IP as DNS, for example `6,192.168.100.1`.
- DNS health is checked through Headscale/Tailscale DNS at `100.100.100.100`.
- Tailscale `accept_dns` is not required; the health check queries `100.100.100.100` directly.
- Healthy profile: default upstreams plus Tailnet conditional upstreams.
- Unhealthy profile: default upstreams only.
- Configuration is written through the AdGuard Home HTTP API, not by editing the YAML path.
- Leaving the password field blank keeps the existing value.
Rollback:
```shell
/usr/sbin/tailscale_adguard_dns_switch --apply-profile down
uci set tailscale.settings.adguard_dns_switch_enabled='0'
uci commit tailscale
/etc/init.d/tailscale reload
```
```shell
opkg update
opkg install --force-overwrite /tmp/luci-*-tailscale*.ipk
```
--------------
## Thanks
- [asvow/luci-app-tailscale](https://github.com/asvow/luci-app-tailscale) — 原版项目
- [Carseason/openwrt-tailscale](https://github.com/Carseason/openwrt-tailscale)
- [immortalwrt/luci-app-zerotier](https://github.com/immortalwrt/luci/blob/master/applications/luci-app-zerotier)
--------------
## Screenshot
<img width="573" alt="Basic" src="https://github.com/user-attachments/assets/bfca389a-bcec-42de-b5dd-b9588fd5db23" />
<img width="577" alt="Advanced" src="https://github.com/user-attachments/assets/d60ce19e-b3f3-43a7-98fc-7df6e2231898" />
<img width="575" alt="Extra" src="https://github.com/user-attachments/assets/6de5eaa7-6c18-48b8-a44a-0eaa311b0b79" />
File diff suppressed because it is too large. Load diff
@@ -1,234 +0,0 @@
# OpenClash Bypass UI Copy Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the OpenClash bypass fallback English with complete Simplified and Traditional Chinese copy that explains the concrete network impact of disabling the protection.
**Architecture:** Keep the existing OpenClash helper, UCI package, RPC method, and four-rule lifecycle unchanged. Update only LuCI msgids, translation catalogs, and tests; then use the Linux/OpenWrt SDK CI artifacts to hot-deploy matching JavaScript and Simplified Chinese LMO to the office router with backup and rollback.
**Tech Stack:** LuCI JavaScript forms, gettext PO/POT catalogs, POSIX shell tests, Node.js tests, GitHub Actions OpenWrt SDK builds, OpenWrt LuCI LMO catalogs.
## Global Constraints
- `OpenClash` remains an untranslated product name.
- The switch must state that disabling it returns Tailscale traffic to OpenClash and removes protection for node connectivity, direct paths, Tailnet DNS, and subnet access.
- Do not change helper behavior, firewall UCI, WAN UDP direct rules, subnet SNAT, Tailscale service state, OpenClash service state, or firewall4 lifecycle.
- Only the office OpenWrt at `192.168.100.1` may be written during deployment verification; `hzsls-openwrt` remains read-only.
- Never source `.env`; read only explicit credential keys.
- Back up the deployed JavaScript and LMO before writing, arm an automatic rollback, and verify page, rules, services, DNS, and logs after deployment.
---
### Task 1: Lock the user-facing contract with failing tests
**Files:**
- Modify: `tests/setting_openclash_bypass_test.js`
- Modify: `tests/package_release_test.sh`
- Test: `tests/setting_openclash_bypass_test.js`
- Test: `tests/package_release_test.sh`
**Interfaces:**
- Consumes: existing `_()` gettext msgids in `setting.js` and `assert_po_entry()` in `package_release_test.sh`.
- Produces: exact required English msgids and Simplified/Traditional Chinese translations for Task 2.
- [ ] **Step 1: Add source-contract assertions to the JavaScript test**
Require these new msgids and reject the old switch copy:
```js
assert(source.includes("_('Protect Tailscale Traffic (Bypass OpenClash)')"), 'toggle must explain that it protects Tailscale traffic');
assert(source.includes("_('Keep Tailscale control connections, direct connections, Tailnet DNS, and subnet traffic outside OpenClash. When disabled, this traffic is handled by OpenClash; node connectivity, direct paths, subnet access, and internal DNS are no longer protected by this feature. Keep this enabled while using OpenClash.')"), 'description must name the protected traffic and disabled impact');
assert(source.includes("_('Enabled; 4 bypass rules are active')"), 'active status must report the concrete rule state');
assert(source.includes("_('Disabled; Tailscale traffic is handled by OpenClash')"), 'disabled status must state the traffic impact');
assert(!source.includes("_('Enable OpenClash Bypass')"), 'old implementation-only toggle copy must be removed');
```
Update runtime expectations from `Enabled and active` to `Enabled; 4 bypass rules are active`.
- [ ] **Step 2: Replace package translation assertions with the new copy**
Require exact PO entries for:
```text
Protect Tailscale Traffic (Bypass OpenClash)
Keep Tailscale control connections, direct connections, Tailnet DNS, and subnet traffic outside OpenClash. When disabled, this traffic is handled by OpenClash; node connectivity, direct paths, subnet access, and internal DNS are no longer protected by this feature. Keep this enabled while using OpenClash.
Enabled; 4 bypass rules are active
Disabled; Tailscale traffic is handled by OpenClash
OpenClash is not installed; bypass is not required
Unsupported; firewall4/nftables is required
```
The exact Simplified Chinese switch description must be:
```text
开启后,Tailscale 控制连接、直连通信、Tailnet DNS 和跨子网流量不会被 OpenClash 接管。关闭后,这些流量将重新经过 OpenClash,节点在线、点对点直连、跨子网访问和 Tailnet DNS 解析不再受本功能保护;如果 OpenClash 规则接管或重定向这些流量,可能导致节点掉线、直连退化为 DERP、跨子网访问或 Tailnet DNS 中断。使用 OpenClash 时必须保持开启。
```
Update `assert_po_entry_mutation_test()` to mutate the new active and disabled translations rather than the removed strings.
- [ ] **Step 3: Run focused tests and verify RED**
Run:
```sh
node tests/setting_openclash_bypass_test.js
sh tests/package_release_test.sh
```
Expected: both fail because `setting.js` and PO/POT catalogs still contain the old copy.
- [ ] **Step 4: Commit the test contract**
```sh
git add tests/setting_openclash_bypass_test.js tests/package_release_test.sh
git commit -m "test: clarify OpenClash bypass impact copy"
```
---
### Task 2: Implement the LuCI copy and translations
**Files:**
- Modify: `htdocs/luci-static/resources/view/tailscale/setting.js`
- Modify: `po/templates/tailscale.pot`
- Modify: `po/zh_Hans/tailscale.po`
- Modify: `po/zh_Hant/tailscale.po`
- Test: `tests/setting_openclash_bypass_test.js`
- Test: `tests/package_release_test.sh`
**Interfaces:**
- Consumes: exact msgid/translation contract from Task 1.
- Produces: LuCI-rendered switch label, explanation, and status strings with matching gettext catalogs.
- [ ] **Step 1: Update the status msgids in `renderOpenclashBypassStatus()`**
Use:
```js
const labels = {
active: _('Enabled; 4 bypass rules are active'),
waiting: _('Enabled; waiting for OpenClash nftables chains'),
disabled: _('Disabled; Tailscale traffic is handled by OpenClash'),
absent: _('OpenClash is not installed; bypass is not required'),
unsupported: _('Unsupported; firewall4/nftables is required'),
error: _('Configuration error')
};
```
- [ ] **Step 2: Update the form label and description**
Use:
```js
o = s.taboption('openclash', form.Flag, 'openclash_bypass_enabled', _('Protect Tailscale Traffic (Bypass OpenClash)'),
_('Keep Tailscale control connections, direct connections, Tailnet DNS, and subnet traffic outside OpenClash. When disabled, this traffic is handled by OpenClash; node connectivity, direct paths, subnet access, and internal DNS are no longer protected by this feature. Keep this enabled while using OpenClash.'));
```
- [ ] **Step 3: Replace matching POT and PO entries**
Update all removed msgids in the template and both language catalogs. Use these Simplified Chinese status strings:
```text
已开启,4 条绕过规则已生效
已开启,正在等待 OpenClash 创建 nftables 链
已关闭,Tailscale 流量将由 OpenClash 处理
未安装 OpenClash,无需绕过
当前系统不支持,仅支持 firewall4/nftables
```
The exact Traditional Chinese switch description must be:
```text
開啟後,Tailscale 控制連線、直連通訊、Tailnet DNS 和跨子網流量不會被 OpenClash 接管。關閉後,這些流量將重新經過 OpenClash,節點在線、點對點直連、跨子網存取和 Tailnet DNS 解析不再受本功能保護;如果 OpenClash 規則接管或重新導向這些流量,可能導致節點離線、直連退化為 DERP、跨子網存取或 Tailnet DNS 中斷。使用 OpenClash 時必須保持開啟。
```
Use these Traditional Chinese status strings:
```text
已開啟,4 條繞過規則已生效
已開啟,正在等待 OpenClash 建立 nftables 鏈
已關閉,Tailscale 流量將由 OpenClash 處理
未安裝 OpenClash,無需繞過
目前系統不支援,僅支援 firewall4/nftables
```
- [ ] **Step 4: Run focused tests and verify GREEN**
Run:
```sh
node tests/setting_openclash_bypass_test.js
sh tests/package_release_test.sh
```
Expected: both pass.
- [ ] **Step 5: Run the complete local verification suite**
Run the same behavior, syntax, JSON, and translation checks as `.github/workflows/release.yml`. Expected: all shell and JavaScript tests pass; syntax, JSON, and `msgfmt --check-format` produce no errors.
- [ ] **Step 6: Commit the implementation**
```sh
git add htdocs/luci-static/resources/view/tailscale/setting.js po/templates/tailscale.pot po/zh_Hans/tailscale.po po/zh_Hant/tailscale.po
git commit -m "fix: explain OpenClash bypass impact"
```
---
### Task 3: Review, CI build, and office hot deployment
**Files:**
- Read: `.github/workflows/release.yml`
- Deploy from CI artifact: `/www/luci-static/resources/view/tailscale/setting.js`
- Deploy from CI artifact: `/usr/lib/lua/luci/i18n/tailscale.zh-cn.lmo`
**Interfaces:**
- Consumes: reviewed commits from Tasks 1 and 2 and successful `ipk` CI artifact.
- Produces: a merged source change and an office router page using matching JavaScript and Simplified Chinese LMO.
- [ ] **Step 1: Request an independent code review**
Review `origin/main...HEAD` for translation completeness, status accuracy, accidental helper behavior changes, and missing tests. Resolve every actionable finding and rerun the complete suite.
- [ ] **Step 2: Push and create a PR**
Push `codex/clarify-openclash-bypass-ui`, create a PR against `main`, and wait for Behavior and syntax tests, Build ipk, and Build apk to finish successfully.
- [ ] **Step 3: Extract exact deployment files from the successful ipk artifact**
Download the `luci-app-tailscale-ipk` Actions artifact for the PR head. Extract the main package and `luci-i18n-tailscale-zh-cn` package, then obtain:
```text
/www/luci-static/resources/view/tailscale/setting.js
/usr/lib/lua/luci/i18n/tailscale.zh-cn.lmo
```
Verify the JavaScript contains the new msgids and the LMO contains the new Simplified Chinese strings before connecting to the router.
- [ ] **Step 4: Back up and arm rollback on the office router**
Create a timestamped directory under `/root/`, copy the current JavaScript and LMO into it, record SHA-256 hashes, and install a five-minute rollback watchdog. The rollback restores both files and removes LuCI index/module caches.
- [ ] **Step 5: Deploy the matching JavaScript and LMO**
Copy both files into place with their existing owner and mode, remove LuCI cache files, and do not restart Tailscale, OpenClash, firewall4, or network.
- [ ] **Step 6: Verify and disarm rollback**
Verify:
```text
The LuCI source and LMO contain the new strings.
The page renders the Chinese switch, explanation, and active status.
openclash_bypass_status reports state=active and rules_present=4.
Tailscale and OpenClash are running.
Tailnet DNS, public DNS, public web access, and cross-subnet reachability work.
No new related errors appear in logread.
```
Disarm the watchdog only after every check passes. If any check fails, run rollback and repeat the same read-only verification.
- [ ] **Step 7: Merge only after CI and office validation**
Squash-merge the PR after all checks and office validation pass. Do not create the `v1.2.8` tag or upgrade the installed package in this task.
@@ -1,188 +0,0 @@
# Tailscale Current Device List Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Show the current OpenWrt device in the existing user-grouped Tailscale device list and label its probe cell as “Current device”.
**Architecture:** Extend `parseStatus()` to normalize the top-level Tailscale `Self` object through the same mapping used for `Peer` entries, adding an `isSelf` marker. Keep grouping, filtering, and pagination unchanged; branch only when rendering the last column so the current device cannot be probed.
**Tech Stack:** LuCI JavaScript, Tailscale `status --json`, Node.js behavior tests, gettext PO translations, shell package checks.
## Global Constraints
- The current device must be grouped by `Self.UserID` under the same user heading as its other devices.
- The current device participates in existing status, subnet, and pagination behavior.
- The probe column displays “Current device” and never invokes the probe helper for `Self`.
- A missing `Self` object remains compatible with the existing peer-only response.
- Existing uncommitted `.superpowers/sdd/task-*-report.md` files must not be modified or committed.
---
### Task 1: Normalize and render the current device
**Files:**
- Modify: `tests/peers_pagination_test.js`
- Modify: `htdocs/luci-static/resources/view/tailscale/peers.js`
**Interfaces:**
- Consumes: Tailscale status JSON containing optional top-level `Self`, `Peer`, and `User` objects.
- Produces: `parseStatus(stdout)` entries with `isSelf: boolean`; the current device row renders a non-interactive “Current device” probe cell.
- [ ] **Step 1: Write the failing normalization test**
Add a `Self` object to the existing status fixture and assert that it is returned with its user metadata and marker:
```javascript
Self: {
ID: 'self-node',
HostName: 'hz-office-openwrt',
DNSName: 'hz-office-openwrt.example.ts.net.',
TailscaleIPs: ['100.64.2.15'],
PrimaryRoutes: ['192.168.100.0/24'],
UserID: 2,
Online: true
}
```
```javascript
const selfPeer = normalizedPeers.find(item => item.isSelf);
assert(selfPeer && selfPeer.name === 'hz-office-openwrt', 'Self should be normalized into the device list');
assert(selfPeer.userName === 'Alpha User', 'Self should resolve its owner through UserID');
assert(selfPeer.routes.join(',') === '192.168.100.0/24', 'Self should preserve its advertised routes');
```
- [ ] **Step 2: Write the failing render test**
Render a list containing a current-device entry and verify that the serialized tree contains `Current device` but no `Probe` action for that row:
```javascript
const currentDevice = {
id: 'self-node',
name: 'hz-office-openwrt',
ip: '100.64.2.15',
userKey: '2',
userName: 'Alpha User',
userLoginName: 'alpha@example.test',
online: true,
lastSeen: '-',
exitNode: false,
routes: ['192.168.100.0/24'],
hasSubnetRoutes: true,
probeTarget: '100.64.2.15',
isSelf: true
};
```
- [ ] **Step 3: Run the focused test and verify RED**
Run:
```bash
node tests/peers_pagination_test.js
```
Expected: FAIL because `parseStatus()` ignores `Self` and the row renderer has no current-device branch.
- [ ] **Step 4: Implement shared status normalization**
In `parseStatus()`, introduce a local `appendPeer(peerId, peer, isSelf)` helper containing the existing normalization logic. Call it once for `parsed.Self` and once per `parsed.Peer` entry. Include:
```javascript
isSelf: !!isSelf
```
Use a stable current-device identifier derived from `Self.ID`, falling back to `Self.PublicKey`, `Self.HostName`, and finally `self`.
- [ ] **Step 5: Render a non-interactive current-device probe cell**
In the device-row loop, branch before creating the probe button:
```javascript
var probeCell = peer.isSelf
? E('span', { style: 'font-weight:600;color:#475569' }, _('Current device'))
: E('div', {
style: 'display:flex;align-items:center;gap:8px;flex-wrap:wrap'
}, [button, resultNode]);
```
Guard `probePeer()` with `if (peer.isSelf || !peer.online) return;` as defense in depth.
- [ ] **Step 6: Run the focused test and verify GREEN**
Run:
```bash
node tests/peers_pagination_test.js
```
Expected: `peers pagination and behavior tests passed`.
### Task 2: Add translations and package regression coverage
**Files:**
- Modify: `po/templates/tailscale.pot`
- Modify: `po/zh_Hans/tailscale.po`
- Modify: `po/zh_Hant/tailscale.po`
- Modify: `tests/package_release_test.sh`
**Interfaces:**
- Consumes: gettext key `Current device` emitted by `peers.js`.
- Produces: Simplified Chinese `当前设备`, Traditional Chinese `目前設備`, and package-level assertions for the new behavior.
- [ ] **Step 1: Add failing package assertions**
Add checks requiring the renderer marker and translations:
```bash
assert_contains "peer.isSelf" htdocs/luci-static/resources/view/tailscale/peers.js
assert_contains "Current device" htdocs/luci-static/resources/view/tailscale/peers.js
assert_po_entry "Current device" "当前设备" po/zh_Hans/tailscale.po
assert_po_entry "Current device" "目前設備" po/zh_Hant/tailscale.po
```
- [ ] **Step 2: Run the package test and verify RED**
Run:
```bash
sh tests/package_release_test.sh
```
Expected: FAIL because the gettext entries do not exist yet.
- [ ] **Step 3: Add gettext entries**
Add `Current device` to the POT template and both PO catalogs:
```po
msgid "Current device"
msgstr "当前设备"
```
```po
msgid "Current device"
msgstr "目前設備"
```
- [ ] **Step 4: Run focused and complete verification**
Run:
```bash
node tests/peers_pagination_test.js
sh tests/package_release_test.sh
for test_file in tests/*_test.sh; do sh "$test_file"; done
git diff --check
```
Expected: all commands exit `0`, the behavior test prints its success message, and no whitespace errors are reported.
- [ ] **Step 5: Commit only feature files**
```bash
git add htdocs/luci-static/resources/view/tailscale/peers.js \
tests/peers_pagination_test.js tests/package_release_test.sh \
po/templates/tailscale.pot po/zh_Hans/tailscale.po po/zh_Hant/tailscale.po \
docs/superpowers/plans/2026-07-18-tailscale-current-device-list.md
git commit -m "feat: show current device in peer list"
```
@@ -1,18 +0,0 @@
# Tailscale 与 mwan3 策略路由兼容实施计划
1. 已为 helper 和 hotplug 增加独立的 shell 行为测试,覆盖创建、幂等、
自愈、关闭、冲突、默认路由保护和所有权保护。
2. 已实现独立配置、init service、hotplug 与 `tailscale_policy_routing` helper;
禁止 helper 操作 firewall、OpenClash 或 mwan3 服务。
3. 已接入软件包升级快照/恢复和 Tailscale 生命周期同步,确保应用升级、重启和
接口事件后配置不丢失。
4. 已通过 rpcd 暴露只读状态,在 LuCI 高级设置中增加可选开关、中文状态和
从真实 LAN 客户端验证的说明;冲突场景保留所有权、route-only `ifupdate` 会
重新评估 exit node 与 mwan3 优先级保护。
5. 已补充简体/繁体中文翻译、包结构检查、rpcd 与 LuCI 视图测试。
6. 已运行可由当前本地终端完整执行的 shell/JavaScript 测试和 `git diff --check`。
OpenClash 信号注入测试会终止当前终端代理,保留给 Linux CI 执行。仅在用户另行
明确确认后,才部署到杭州办公室 OpenWrt 验证;不写入 hzsls-openwrt。
7. 已在独立评审后补强:统一规范化 mwan3 标记掩码的十六进制大小写;软件包卸载
不会遗留无 UCI 所有权的精确 `priority 1000 lookup 52` 规则;策略服务以 15 秒
间隔静默校验已拥有的运行态规则,覆盖 tailscaled 单独重启和路由状态变更。
@@ -1,202 +0,0 @@
# AdGuard DNS Auto Switch Design
## Goal
Add an optional AdGuard DNS auto-switch feature to `luci-app-tailscale`.
The feature lets OpenWrt LAN clients use Headscale DNS Records while Tailscale is healthy, and automatically fall back to public DNS behavior while Tailscale DNS is unhealthy. Tailscale itself keeps its official behavior; OpenWrt may run with either `accept_dns=1` or `accept_dns=0` because the switch health check queries `100.100.100.100` directly.
This is intended for setups where OpenWrt runs local AdGuard Home as the LAN DNS entry point.
## Non-Goals
- Do not modify Tailscale's native DNS behavior.
- Do not add or maintain per-service DNS records in OpenWrt.
- Do not introduce a separate DNS server.
- Do not require users to know or edit the AdGuard Home config file path.
- Do not depend on dnsmasq layout, except for user-provided upstream entries such as `[/lan/]127.0.0.1:5353`.
## User Model
Users maintain DNS records in two authoritative places:
- Internal overrides in Headscale DNS Records.
- Public records in public DNS, such as AliDNS.
Each OpenWrt instance only configures a default public upstream list and a small set of Tailnet conditional upstreams.
## Profiles
The switch manages AdGuard Home `upstream_dns`.
### Down Profile
The down profile is the user-configured default upstream list. Example:
```text
[/lan/]127.0.0.1:5353
223.5.5.5
223.6.6.6
119.29.29.29
```
This is used when Tailscale DNS is unhealthy. Example outcomes:
- `sso.litata.com` resolves through public DNS.
- `tailscale.litata.com` resolves through public DNS.
- `deploy.litata.com` resolves through public DNS.
- `code.litata.com` returns public DNS behavior, normally `NXDOMAIN` if no public record exists.
### Up Profile
The up profile is:
```text
default upstreams + tailnet conditional upstreams
```
Example:
```text
[/lan/]127.0.0.1:5353
[/litata.tailnet/]100.100.100.100
[/litata.com/]100.100.100.100
223.5.5.5
223.6.6.6
119.29.29.29
```
This is used when Tailscale DNS is healthy. Example outcomes:
- `sso.litata.com` resolves to the Headscale DNS Record internal IP and avoids public OTP policy.
- `code.litata.com` resolves to the Headscale DNS Record internal IP.
- `deploy.litata.com` falls through Headscale split DNS to public DNS.
- `litata.tailnet` names resolve through MagicDNS.
## Health Check
The switch determines health by querying the configured health check domain through the configured Tailnet DNS server.
Example:
```sh
nslookup sso.litata.com 100.100.100.100
```
The result is healthy only when at least one returned IP equals one of the configured expected IPs, for example `10.10.6.156`.
Recommended hysteresis:
- Switch to up after 2 consecutive successful checks.
- Switch to down after 2 consecutive failed checks.
- Default check interval: 10 seconds.
## Strong Enablement Checks
The feature must not start unless all checks pass:
- AdGuardHome process is running.
- Port 53 is listened by AdGuardHome.
- LAN DHCP explicitly advertises the OpenWrt LAN IP as DNS, for example `6,192.168.100.1`.
- AdGuard API is reachable and writable.
- The Tailnet DNS health check can return the expected IP at least once.
LuCI must show failing checks and block enabling the feature until they pass.
## AdGuard Integration
Use AdGuard Home HTTP API rather than exposing `/etc/AdGuardHome.yaml` in LuCI.
Default API base URL:
```text
http://127.0.0.1:3000
```
The implementation should support authenticated AdGuard instances with username and password. The password must be treated like the Tailscale auth key: do not echo it back in LuCI; show a configured placeholder and keep the existing value when the field is empty.
API operations:
- Read current DNS config.
- Write `upstream_dns` for the selected profile.
- Clear cache after profile changes when configured.
If API write fails, keep the last known profile and log the error.
## Runtime Components
Add an independent script:
```text
/usr/sbin/tailscale_adguard_dns_switch
```
Responsibilities:
- Read UCI configuration.
- Run strong preflight checks.
- Poll health state.
- Apply the up or down AdGuard profile.
- Clear AdGuard cache after profile changes.
- Log state changes and rate-limit repeated failures.
Add a named procd instance under the existing `/etc/init.d/tailscale` service.
Responsibilities:
- Start only when the feature is enabled.
- Run the switch script in loop mode.
- Restart on failure via procd respawn.
- Reload when `tailscale` UCI changes.
- Apply the down profile during Tailscale service stop/reload so persisted AdGuard upstreams do not keep pointing at Tailnet DNS after the watcher stops.
## LuCI Configuration
Add an "AdGuard DNS Auto Switch" section to the existing Tailscale settings page.
Fields:
- Enable AdGuard DNS auto switch.
- Default upstream DNS list.
- Tailnet conditional upstream DNS list.
- Health check domain.
- Expected health check IP list.
- Check interval.
- Success threshold.
- Failure threshold.
- Clear AdGuard cache after switching.
- AdGuard API URL.
- AdGuard username.
- AdGuard password.
The section should include a read-only status panel for the strong enablement checks.
## Cache Policy
Keep AdGuard DNS cache disabled by default for this feature.
If cache is enabled by the user, the switch should clear AdGuard cache after each profile change. Users that want caching should use a short maximum TTL such as 30 seconds and keep optimistic cache disabled.
Tailscale DNS TTL is not controlled by this feature.
## Testing
Add shell tests for the switch script:
- Profile generation from default and Tailnet upstream lists.
- Healthy result detection from `nslookup` output.
- Up transition after configured success threshold.
- Down transition after configured failure threshold.
- AdGuard API payload generation.
- Missing/invalid configuration handling.
- Preflight failure behavior.
Existing `tailscale_keepalive` tests must continue passing.
Manual verification on OpenWrt should cover:
- Up profile makes `sso.litata.com` resolve to the internal Headscale DNS Record IP.
- Down profile makes `sso.litata.com` resolve through public DNS.
- `code.litata.com` fails in down state when no public record exists.
- `deploy.litata.com` resolves through public DNS in both states.
- Tailscale can restart and reconnect without self-bootstrap DNS failure.
@@ -1,209 +0,0 @@
# Tailscale Peer Observability Design
## Goal
Add a LuCI page for observing all Tailscale peers and manually probing whether traffic to a peer is direct or relayed through DERP.
This feature is diagnostic only. It does not change Tailscale configuration, keepalive configuration, firewall rules, DNS settings, or network interfaces.
## User Experience
Add a top-level Tailscale child page:
- Menu path: `admin/vpn/tailscale/peers`
- Title: `Peers`
- Chinese title: `对端列表`
- Order: after `Interface Info`, before `Logs`
The page shows a compact table of every peer from `tailscale status --json`.
Columns:
- Name: short DNS name when available, otherwise host name, DNS name, or Tailnet IP.
- Tailnet IP: first `TailscaleIPs` value.
- Status: online or offline.
- Last Seen: `LastSeen` when available.
- Role: badges for exit node option and advertised subnet routes.
- Advertised Subnets: `PrimaryRoutes`, shown as comma-separated CIDRs.
- Probe: a button for one-shot connectivity probing.
- Probe Result: latest result for that row.
Filters:
- All
- Online
- Advertising subnets
Each row has a `Probe` button. Clicking it probes only that peer and updates that row. Page load does not automatically ping all peers.
Probe result labels:
- `Direct`: Tailscale ping reached the peer directly.
- `DERP`: Tailscale ping used DERP relay.
- `Failed`: the probe command failed or the peer did not answer.
- `Unknown`: output did not match known direct or DERP patterns.
The first version does not include a `Probe all` button. That avoids accidental bursts of probes on larger tailnets.
## Data Flow
The LuCI view loads status data using:
```sh
/usr/sbin/tailscale status --json
```
The view parses peers in JavaScript and renders the table. It also polls status periodically, matching existing page patterns.
Manual probes use a helper command:
```sh
/usr/sbin/tailscale_peer_probe <peer>
```
The view calls the helper via `fs.exec()`. The helper validates the peer argument, runs `tailscale ping`, parses the output, and prints JSON.
Example helper output:
```json
{
"peer": "hzsls-openwrt",
"ok": true,
"path": "direct",
"latency_ms": 12.4,
"summary": "direct 12.4 ms",
"raw": "pong from hzsls-openwrt ..."
}
```
For DERP:
```json
{
"peer": "jiaxing-idc-wg",
"ok": true,
"path": "derp",
"latency_ms": 41.8,
"relay": "litata",
"summary": "DERP litata 41.8 ms",
"raw": "pong from jiaxing-idc-wg via DERP(litata) ..."
}
```
For failure:
```json
{
"peer": "missing-peer",
"ok": false,
"path": "failed",
"summary": "tailscale ping failed",
"raw": "..."
}
```
Exact parsing patterns will be covered by fixtures from the installed Tailscale version before implementation. The helper will preserve raw output so unfamiliar formats remain debuggable.
## Components
### LuCI view
Add:
```text
htdocs/luci-static/resources/view/tailscale/peers.js
```
Responsibilities:
- Fetch `tailscale status --json`.
- Render all peers.
- Apply client-side filters.
- Call `tailscale_peer_probe` for one selected peer.
- Display loading, success, and failure states per row.
### Probe helper
Add:
```text
root/usr/sbin/tailscale_peer_probe
```
Responsibilities:
- Accept exactly one peer argument.
- Validate the argument before running anything.
- Run `/usr/sbin/tailscale ping`.
- Classify direct, DERP, failed, or unknown.
- Output JSON only.
Argument validation:
- Length: 1 to 253 characters.
- Allowed characters: letters, numbers, `_`, `-`, `.`, `:`.
- No shell evaluation. The helper passes arguments as command arguments, not interpolated shell fragments.
### Menu and ACL
Update:
```text
root/usr/share/luci/menu.d/luci-app-tailscale.json
root/usr/share/rpcd/acl.d/luci-app-tailscale.json
```
ACL grants read-only exec access to:
```sh
/usr/sbin/tailscale_peer_probe
```
The feature remains read-only from LuCI's perspective.
## Error Handling
If `tailscale status --json` fails, the page shows a clear error and does not render stale peer rows.
If no peers exist, the page shows `No peers found`.
If a probe is already running for a row, that row's probe button is disabled until it completes.
If helper output is invalid JSON, the row shows `Failed` and includes a short error message.
If Tailscale is not running, both status loading and probe actions surface that state instead of silently failing.
## Tests
Add or update tests:
- Static package test confirms the new `peers.js`, menu entry, ACL entry, and helper exist.
- Shell tests for `tailscale_peer_probe` cover:
- invalid peer arguments are rejected;
- direct ping output is classified as `direct`;
- DERP ping output is classified as `derp`;
- failed ping output is classified as `failed`;
- unknown successful output is classified as `unknown`.
- JavaScript syntax check includes `peers.js`.
- Shell syntax check includes `tailscale_peer_probe`.
## Deployment
Source changes go through the existing PR branch and release package flow.
For office OpenWrt hot deployment during development:
1. Backup current LuCI view and helper files under `/tmp`.
2. Upload `peers.js`, menu JSON, ACL JSON, and helper.
3. Reload only affected LuCI/rpcd metadata if needed.
4. Verify by reading the served LuCI static file over HTTP and calling the helper with a known peer.
No network reload, Tailscale restart, or firewall restart is part of this feature.
## Non-Goals
- No automatic probing of all peers on page load.
- No keepalive configuration changes.
- No Headscale API integration.
- No historical latency chart.
- No persistent storage of probe results.
@@ -1,359 +0,0 @@
# Tailscale OpenClash Bypass Design
## Status
Design approved on 2026-07-16; final-review corrections approved on 2026-07-17.
## Background
OpenClash can intercept traffic generated by `tailscaled` before Tailscale completes
peer-to-peer endpoint negotiation. The affected traffic falls into two groups:
- locally generated Tailscale outer UDP and TCP traffic, identified by Tailscale's
`0x80000` packet mark;
- traffic entering the router from `tailscale0` before it is forwarded to a local
network or subnet route.
The office router currently bypasses OpenClash with four manually maintained
nftables rules in OpenClash's developer custom firewall script. The rules work,
but the installation is device-specific and is not owned by the customized
`luci-app-tailscale` package.
The package already has a separate WAN-direct feature that manages firewall UCI
configuration. That feature and the OpenClash bypass must remain independent.
The OpenClash bypass must not use a firewall4 reload as a lifecycle mechanism.
## Goals
- Automatically install and maintain the four required bypass rules when the
customized package is installed on an OpenWrt device using OpenClash.
- Use OpenClash's supported developer custom firewall script hook so rules are
recreated after OpenClash creates its built-in nftables chains.
- Preserve all user-owned content in the OpenClash custom firewall script.
- Make applying and removing rules idempotent and ownership-safe.
- Provide a LuCI option that is enabled by default when OpenClash is present and
can be explicitly disabled.
- Support only firewall4/nftables.
- Keep the WAN-direct and OpenClash-bypass lifecycles completely independent.
## Non-Goals
- Supporting firewall3 or legacy iptables.
- Fixing OpenClash's own firewall-only reload behavior.
- Monitoring or repairing arbitrary firewall4 reloads initiated outside this
feature.
- Restarting, enabling, disabling, or otherwise managing the OpenClash service.
- Replacing OpenClash's native local-network destination bypass logic.
- Dynamically synchronizing Tailscale routing table 52 into OpenClash rules.
- Modifying `/etc/config/firewall` for the OpenClash bypass.
## Hard Boundary Between Features
The package contains two separate features with no calls between them.
### WAN-Direct Flow
The existing WAN-direct feature owns only its firewall UCI objects and its
firewall4 reload behavior.
```text
WAN-direct setting changes
-> update firewall UCI objects
-> reload firewall4
-> verify WAN-direct firewall state
```
The WAN-direct flow must not:
- edit the OpenClash custom firewall script;
- invoke the OpenClash bypass helper;
- inspect OpenClash nftables chains;
- restart or reload OpenClash;
- repair OpenClash after a firewall4 reload.
### OpenClash-Bypass Flow
The new OpenClash-bypass feature owns only its hook block and four runtime
nftables rules.
```text
OpenClash creates its built-in nftables chains
-> OpenClash executes its developer custom firewall script
-> the managed hook invokes the package helper
-> the helper reconciles four bypass rules
```
The OpenClash-bypass flow must not:
- read or write firewall UCI objects;
- edit `/etc/config/firewall`;
- reload or restart firewall4;
- invoke the WAN-direct helper or apply path;
- restart, reload, enable, or disable OpenClash.
Changing settings in one flow must not execute any action from the other flow.
If both settings are changed in one LuCI submission, each flow executes only its
own independent apply operation.
## Components
### UCI Setting and LuCI Control
Add an OpenClash-bypass option to the Tailscale application's own UCI section.
Its effective default is enabled when OpenClash is installed. When OpenClash is
not installed, the feature silently performs no action and does not create an
OpenClash directory or custom script.
The LuCI control supports these operational states:
- enabled and active;
- enabled and waiting for OpenClash chains;
- disabled;
- unsupported because the device is not using firewall4/nftables;
- configuration error requiring user attention.
The setting is independent from all WAN-direct options.
### Package-Owned Helper
Install an executable helper at:
```text
/usr/sbin/tailscale_openclash_bypass
```
The helper exposes these operations:
- `sync`: re-read the app-owned UCI setting and reconcile the complete enabled or
disabled state under one exclusive lock;
- `reconcile-hook`: install or update the managed hook block without changing
user content; this remains a focused diagnostic/test operation and is not used
by the procd lifecycle;
- `apply`: reconcile the four runtime nftables rules;
- `cleanup`: remove only the managed hook block and managed nftables rules;
- `status`: return a machine-readable operational state for LuCI and tests.
All mutating operations are idempotent and use an OpenWrt-compatible lock. The
procd service invokes only `sync`, so one UCI decision, hook reconciliation, and
runtime-rule reconciliation cannot be interleaved with a concurrent enable,
disable, OpenClash startup, or LuCI apply.
### Managed OpenClash Hook
The helper manages one uniquely delimited block in:
```text
/etc/openclash/custom/openclash_custom_firewall_rules.sh
```
The block contains only a guarded invocation of the package helper:
```sh
# BEGIN luci-app-tailscale 托管:Tailscale 绕过 OpenClash
if test -x /usr/sbin/tailscale_openclash_bypass; then
/usr/sbin/tailscale_openclash_bypass apply
fi
# END luci-app-tailscale 托管:Tailscale 绕过 OpenClash
```
Hook reconciliation must:
- preserve all bytes outside the managed block;
- preserve the file's mode and ownership;
- identify markers only when an entire line exactly equals the marker, using
BusyBox 1.36-compatible tools; `grep -b` is not available on the target;
- validate the complete managed body and replace a stale managed body with the
canonical block;
- write through a temporary file and atomically replace the original only after
`sh -n` succeeds;
- restore the original immediately if validation or replacement fails;
- refuse to modify a file containing malformed or duplicate managed markers;
- create no file when OpenClash is absent;
- create a minimal executable custom script only when OpenClash is installed but
the documented hook path is missing.
Temporary hook and nftables files are tracked explicitly and removed by signal
and exit traps. Replacement restores numeric ownership before restoring mode so
`chown` cannot clear special mode bits after `chmod`.
The marker comments and nftables rule comments are written in Chinese, with a
stable `luci-app-tailscale` ownership prefix for reliable cleanup.
## Managed nftables Rules
The helper manages exactly these four rules in table `inet fw4`:
| Chain | Match | Action |
| --- | --- | --- |
| `openclash_mangle_output` | `meta mark & 0x00ff0000 == 0x00080000` | `return` |
| `openclash_output` | `meta mark & 0x00ff0000 == 0x00080000` | `return` |
| `openclash_mangle` | `iifname "tailscale0"` | `return` |
| `openclash` | `iifname "tailscale0"` | `return` |
Each rule includes a counter and a unique comment beginning with
`luci-app-tailscale:`. The rules are inserted at the head of their respective
OpenClash chains so later interception rules cannot run first.
Before applying, the helper verifies that firewall4/nftables and all four target
chains exist. It then reconciles the complete rule set as one operation. It does
not leave a partial rule set when any target chain is missing.
Rules are identified for update and removal only by their unique ownership
comments. The helper does not remove equivalent rules written by the user or
another package. Repeated application leaves exactly one owned rule in each
target chain.
An absent `inet fw4` table or an already removed target chain is a successful
cleanup no-op. If a chain disappears between a table snapshot and a chain read,
the helper refreshes table state before classifying the result as waiting,
unsupported, or an actual error.
## Existing Site-to-Site SNAT Contract
The existing `子网互通` option remains outside the OpenClash flow. It controls
both Tailscale `--snat-subnet-routes` and the app-owned firewall4 zone's `masq`
option. Enabling it means `--snat-subnet-routes=false` and
`firewall.tszone.masq='0'`.
The `tailscale` firewall zone exists whenever either forwarding access rules are
selected or site-to-site no-SNAT is enabled. Forwarding sections are still
created only for selected access directions. Therefore no-SNAT remains effective
when the access list is empty, without creating unrelated forwarding rules.
## Destination Bypass Responsibility
The helper does not create destination-based rules for Tailnet addresses or
advertised subnet routes. OpenClash's native local-network handling remains
responsible for those destinations.
In particular, the feature does not enumerate routes from Tailscale routing table
52 and does not add `100.64.0.0/10` or private subnet routes to OpenClash. This
keeps route ownership with Tailscale and destination bypass ownership with
OpenClash.
## Lifecycle
### Package Install or Upgrade
1. Use the standard LuCI package post-install path so cache invalidation and
`rpcd` reload remain intact; do not add a package-specific start/enable hook.
2. OpenWrt's `default_postinst` starts/enables the packaged init script.
3. The one-shot init script invokes `sync`, which re-reads UCI under one lock.
4. If OpenClash is absent, finish without creating a hook or changing rules.
5. If firewall4/nftables is unsupported, report `unsupported` and make no hook or
rule change.
6. When enabled and firewall4 exists, reconcile the managed hook. If all four
OpenClash chains exist, reconcile the rules in one nft transaction.
7. If the chains do not exist, retain the canonical hook and report `waiting`;
do not start or restart OpenClash. Its next successful startup executes the
hook.
### OpenClash Start or Restart
OpenClash creates its built-in rules and then executes its developer custom
firewall script. The managed block calls `apply`, which validates the target
chains and reconciles the four rules. No firewall4 reload occurs.
### Enable Through LuCI
Enabling follows the same steps as package installation: reconcile the hook and
apply immediately only when all target chains already exist. Missing chains
produce the `waiting` state without managing the OpenClash service.
### Disable Through LuCI
Disabling removes the managed hook block and the four owned runtime rules. It
does not remove user rules, reload firewall4, or restart OpenClash.
### Package Removal
The package removal hook invokes `cleanup` while the helper is still present.
Cleanup is best-effort and ownership-scoped. An absent OpenClash installation,
absent chains, or an already removed hook is a successful no-op.
## Failure Handling
- Missing OpenClash is a silent no-op, not an error.
- Unsupported firewall backend produces an explicit unsupported status and no
changes.
- Missing target chains produce a waiting status and no partial rules.
- Malformed or duplicate managed hook markers produce an error and leave the
custom script unchanged.
- nftables command failures leave a nonzero status and are logged with `logger`.
- The helper never responds to a failure by reloading firewall4 or restarting
OpenClash.
- LuCI displays the helper result without reporting success when hook or rule
reconciliation failed.
- UCI booleans use OpenWrt semantics: `0`, `false`, `off`, `no`, and `disabled`
are false; known true aliases are true; malformed values do not silently enable
the feature.
OpenClash may fail to rebuild its own chains after a firewall4 reload. That is an
independent OpenClash lifecycle problem. This feature reports missing chains and
waits for a successful OpenClash startup; it does not cross into service repair.
## Testing
### Automated Tests
Add shell and LuCI tests covering:
- default-enabled detection when OpenClash is installed;
- silent no-op behavior when OpenClash is absent;
- rejection of non-firewall4 backends;
- hook insertion while preserving user content, mode, and ownership;
- hook update and repeated reconciliation without duplication;
- malformed and duplicate marker rejection without file changes;
- hook cleanup without removing surrounding user content;
- exact generation of all four nftables rules;
- insertion at the head of each target chain;
- unique Chinese ownership comments and counters;
- repeated apply leaving one owned rule per chain;
- no partial application when a chain is missing;
- cleanup removing only owned rules;
- package install, upgrade, disable, and removal lifecycle behavior;
- LuCI status and toggle behavior;
- static assertions that the OpenClash helper contains no firewall UCI writes,
firewall reloads, OpenClash service operations, or WAN-direct calls;
- static assertions that the WAN-direct apply path contains no OpenClash bypass
calls or OpenClash service repair.
Existing WAN-direct tests remain separate and must continue to pass unchanged.
### Device Verification
Remote device verification is a separate, explicitly approved operation. It
must verify:
1. Existing user content in the custom script survives migration.
2. Enabling the feature creates one managed hook block.
3. The four rules are present at the heads of the expected chains.
4. Reapplying does not create duplicates.
5. A normal OpenClash restart recreates all four rules through the official hook.
6. OpenClash proxy traffic remains functional.
7. Tailscale outer traffic bypasses OpenClash and cold peer negotiation reaches a
direct path when the network endpoints permit it.
8. Disabling removes only the package-owned hook and rules.
Device verification for this flow must not reload firewall4. WAN-direct behavior
is verified independently.
## Rollback
The operational rollback is to disable the OpenClash-bypass option, verify that
cleanup removed the owned hook and rules, and then reinstall the previous package
version if a package rollback is required. Cleanup preserves user content.
Because the feature does not write firewall UCI or manage services, rollback
requires no firewall4 or OpenClash restart.
## Documentation After Implementation
After package deployment and device verification:
- replace the office router's manual OpenClash rule procedure with the
package-managed state;
- remove obsolete manual rollback instructions that apply only to the old
hand-maintained script;
- document the LuCI toggle, supported backend, managed hook path, status states,
and ownership-scoped rollback;
- keep the WAN-direct and OpenClash-bypass documentation in separate sections.
@@ -1,68 +0,0 @@
# OpenClash 绕过开关中文说明设计
## 背景与根因
办公室 OpenWrt 热部署了包含 OpenClash 绕过页面的新版 JavaScript,但仍安装旧版 `luci-i18n-tailscale-zh-cn`。设备上的 `tailscale.zh-cn.lmo` 不包含新增 msgid,因此 LuCI 将开关、说明和状态回退为英文。
这不是浏览器缓存问题。修复必须同时更新源码中的 msgid、简体/繁体翻译和设备上的编译语言目录,不能只改 JavaScript。
## 用户意图
页面必须直接解释关闭开关的网络影响,而不只描述内部实现。用户应能在保存前明确知道:关闭会让 Tailscale 流量重新进入 OpenClash,并使节点在线、点对点直连、Tailnet DNS 和跨子网访问失去绕过保护。
## 页面设计
`OpenClash` 作为产品名称保留英文。
开关名称:
> 保护 Tailscale 流量(绕过 OpenClash)
简体中文说明:
> 开启后,Tailscale 控制连接、直连通信、Tailnet DNS 和跨子网流量不会被 OpenClash 接管。关闭后,这些流量将重新经过 OpenClash,节点在线、点对点直连、跨子网访问和 Tailnet DNS 解析不再受本功能保护;如果 OpenClash 规则接管或重定向这些流量,可能导致节点掉线、直连退化为 DERP、跨子网访问或 Tailnet DNS 中断。使用 OpenClash 时必须保持开启。
繁体中文说明使用相同语义。
状态文案:
- `active`:已开启,4 条绕过规则已生效
- `waiting`:已开启,正在等待 OpenClash 创建 nftables 链
- `disabled`:已关闭,Tailscale 流量将由 OpenClash 处理
- `absent`:未安装 OpenClash,无需绕过
- `unsupported`:当前系统不支持,仅支持 firewall4/nftables
- `error`:配置错误
## 行为边界
页面文案只解释既有行为,不改变 helper 的所有权和生命周期:
- 开启时继续维护 4 条包拥有的 nftables `return` 规则和一个托管 hook。
- 关闭时继续删除这 4 条规则和托管 hook。
- 不停止 Tailscale。
- 不修改 WAN UDP 端口放行。
- 不修改子网 SNAT。
- 不 reload firewall4,不启停或重启 OpenClash。
## 测试设计
先扩展现有测试并确认 RED:
- `tests/package_release_test.sh` 精确验证简体和繁体中文翻译。
- `tests/setting_openclash_bypass_test.js` 验证页面使用新的 msgid 和各状态文案。
- 测试拒绝旧的 `Enable OpenClash Bypass` 及只描述实现、不描述关闭影响的说明。
实现后运行完整 shell、JavaScript、语法、JSON 和翻译检查。GitHub Actions 继续负责 Linux/OpenWrt SDK 的 `ipk`、`apk` 构建。
## 办公室验证与回滚
源码与 CI 通过后,只写办公室 OpenWrt,不修改杭州机房 OpenWrt。
部署前备份当前 `setting.js`、`tailscale.zh-cn.lmo` 和相关 LuCI 缓存状态。部署匹配版本的 JavaScript 与编译语言目录后清理 LuCI 缓存,并验证:
- 开关、说明和状态均显示中文。
- OpenClash 绕过状态仍为 active,规则数仍为 4。
- Tailscale、OpenClash、Tailnet DNS、公共 DNS 和跨子网访问正常。
- 日志没有新增相关错误。
任何页面加载错误、语言目录错误或网络回归都会触发回滚:恢复备份的 JavaScript 和 LMO,重新清理 LuCI 缓存并执行同等级别验证。
@@ -1,27 +0,0 @@
# Tailscale 当前设备列表设计
## 目标
在“对端”页面的现有用户/设备两层列表中展示当前 OpenWrt 设备。当前设备应归入其 Tailscale 用户分组,并与其他设备共享状态、地址、角色、已发布网段、筛选和分页逻辑。
## 数据处理
`tailscale status --json` 将当前设备放在顶层 `Self`,其他设备放在 `Peer`。页面解析状态时应使用同一套规范化逻辑处理两类节点,并为 `Self` 增加 `isSelf: true` 标记。
当前设备使用 `Self.UserID` 查找顶层 `User` 中的显示名称和登录名,因此与该用户的其他设备显示在同一分组下。若 `Self` 缺失,则保持现有行为,仅显示 `Peer`。
## 页面行为
- 当前设备与普通设备使用相同的名称、Tailnet IP、在线状态、最后在线时间、角色和已发布网段列。
- 当前设备参与“全部”“在线”和“发布子网”筛选。
- 当前设备参与设备数量统计和现有分页。
- “探测”列对当前设备固定显示“当前设备”,不渲染探测按钮,也不允许调用探测命令。
- 普通在线、离线设备的现有探测行为保持不变。
## 测试
- 验证 `Self` 被规范化并带有 `isSelf` 标记。
- 验证 `Self` 根据 `UserID` 进入正确的用户分组。
- 验证缺少 `Self` 时保持兼容。
- 验证当前设备行显示“当前设备”且没有可点击的探测按钮。
- 运行现有对端页面测试和完整项目测试,确认筛选、分页、探测及打包检查无回归。
@@ -1,91 +0,0 @@
# Tailscale 与 mwan3 策略路由兼容设计
**状态:已实现,待杭州办公室 OpenWrt 部署验证**
## 背景
Tailscale 在 OpenWrt 上维护 IPv4 路由表 `52`,并通常安装优先级为
`5270` 的 `lookup 52` 规则。启用了 mwan3 的路由器会在更低的优先级安装
带 fwmark 的策略规则,例如 `2001`、`2002`。LAN 转发流量先被 mwan3 标记后,
会命中这些规则并被发往 WAN,无法到达后续的 Tailscale 路由表。
本功能为存在该冲突的路由器提供一个显式、可选的修复:在 mwan3 的 fwmark
规则之前安装 `priority 1000 lookup 52`。当目标不在表 `52` 中时,该 lookup
不产生路由结果,内核会继续评估后续 mwan3 规则;它不接管普通公网流量。
## 目标
1. 检测 mwan3 的早期 fwmark 规则与 Tailscale 表 `52` 的优先级冲突。
2. 通过独立 UCI 开关维护应用拥有的 `network` 规则:
`network.ts_mwan3_table52`、IPv4、优先级 `1000`、`lookup 52`。
3. 在接口 `ifup` / `ifupdate` 后只做幂等的运行态自愈,不重启任何服务。
4. 在 LuCI 中呈现启用状态、阻断原因和真实 LAN 客户端的验证要求。
5. 当表 `52` 存在默认路由(例如使用 Tailscale exit node)时拒绝启用,
防止把普通公网流量绕过 mwan3。
## 非目标与边界
- 不修改 `/etc/config/firewall`,不重载 firewall4,不操作 nftables。
- 不管理、重启或重载 mwan3。
- 不管理 OpenClash 服务或其配置;OpenClash bypass 仍是独立流程。
- 不直接修改或替换 tailscaled 所有的 `5270 lookup 52` 运行态规则。
该规则由 tailscaled 管理,重启或路由同步时会重新生成。
- 不将本功能用于发布 Tailscale 子网路由,也不替代 Headscale 的路由审批。
## 三条独立流程
| 流程 | 所有者 | 可修改的资源 |
| --- | --- | --- |
| WAN 直连 | Tailscale LuCI WAN 设置 | firewall UCI / firewall4 |
| OpenClash 绕过 | OpenClash 自定义防火墙 hook | package-owned helper 的 nft 规则 |
| mwan3 优先级兼容 | `tailscale_policy_routing` helper | `network` UCI 与运行态 `ip rule` |
三条流程不能互相 reload 或管理对方服务。
## 配置与所有权
- 独立配置包:`/etc/config/tailscale_policy_routing`。
- 开关:`tailscale_policy_routing.settings.enabled`,默认关闭。
- 应用管理的网络规则固定命名为 `network.ts_mwan3_table52`,并使用固定的
名称、family、priority 与 lookup 值证明所有权。
- 如果同名 UCI section 或运行态优先级 `1000` 被其他配置占用,helper 失败
并记录日志,不接管、不删除第三方规则;冲突后仍保留应用的 UCI 所有权声明,
以便第三方规则撤销后安全恢复。
- 比较 mwan3 的 `mmx_mask` 与运行态 `ip rule` 时统一使用小写十六进制,避免 UCI
与 `iproute2` 输出的大小写差异绕过优先级保护。
- 关闭开关时,helper 仅删除经所有权确认的 UCI rule 及其对应的精确运行态
`1000: from all lookup 52` 规则。带 `from`、`to` 或 `fwmark` 条件的表 52
规则均视为手工规则并保持不变。
## 生命周期
1. 保存 LuCI 配置后,策略路由 init service 调用 helper 的 `sync`。
2. Tailscale 正常启动或重新应用配置完成后,调用 helper 的 `sync`,以便在
exit-node 状态变化时重新评估默认路由保护。
3. 接口 `ifup` / `ifupdate` 调用 helper 的 `ensure`;它不写 UCI,
只在配置已被确认拥有且规则缺失时恢复运行态规则。若表 `52` 此时有默认路由,
它只删除应用拥有的运行态优先级规则,保留 UCI 声明以便退出节点撤销后恢复。
4. 若已有 mwan3 fwmark 规则的优先级数值小于 `1000`,helper 不安装规则;若原有
运行态规则已存在则只移除运行态规则、保留 UCI 声明,并在 LuCI 显示阻断原因。
5. 策略服务启动后以 15 秒间隔静默执行 `ensure`,覆盖 `tailscaled` respawn 与通过
CLI 改变退出节点/路由的场景;它不写 UCI、不重启任何网络或防火墙服务。
6. 软件包卸载时先以严格模式调用 `cleanup`;若 UCI 所有权已丢失但仍有精确的
`1000: from all lookup 52` 运行态规则,清理失败并中止卸载,防止卸载后遗留未知
路由行为。普通服务 stop 不删除持久化配置。
## 运行状态
- `active`:持久化和运行态规则均由本应用管理。
- `waiting`:开关已启用,等待后续接口/Tailscale 生命周期同步。
- `blocked_default_route`:表 `52` 有默认路由,禁止提升优先级。
- `blocked_mwan3_priority`:已有 mwan3 fwmark 策略规则优先级数值小于 `1000`,本
功能无法保证 table `52` 优先。
- `blocked_priority_conflict`:优先级 `1000` 已被其他规则占用。
- `blocked_ownership`:同名 UCI section 或同优先级运行态规则不属于本应用。
- `disabled`:功能关闭,且不存在本应用残留。
## 验证
不能只使用 OpenWrt 本机的无 fwmark `ip route get`。验收须从真实 LAN 客户端
发起,或使用与 mwan3 相同 fwmark 的路由模拟,验证 Tailnet 地址和远端子网
地址命中表 `52`,普通公网目标仍由 mwan3 处理。
@@ -13,9 +13,12 @@
return view.extend({
async load() {
const res = await fs.exec('/sbin/ip', ['-s', '-j', 'ad']);
let res = await fs.exec('/sbin/ip', ['-s', '-j', 'ad']);
if (res.code !== 0 || !res.stdout || res.stdout.trim() === '') {
ui.addNotification(null, E('p', {}, _('Unable to get interface info: %s.').format(res.message)));
res = await fs.exec('/usr/bin/ip', ['-s', '-j', 'ad']);
}
if (res.code !== 0 || !res.stdout || res.stdout.trim() === '') {
ui.addNotification(null, E('p', {}, _('Unable to get interface info: %s.').format(res.message || res.stderr || 'unknown error')));
return [];
}
@@ -4,52 +4,6 @@
'require ui';
'require view';
function translateLogText(message) {
return (typeof _ === 'function') ? _(message) : message;
}
function formatLogLine(log) {
const logParts = String(log || '').split(' ').filter(Boolean);
const statusMappings = {
'daemon.err': { status: 'StdErr', startIndex: 6 },
'daemon.notice': { status: 'Info', startIndex: 6 },
'user.err': { status: 'StdErr', startIndex: 6 },
'user.notice': { status: 'Info', startIndex: 6 }
};
if (logParts.length < 6)
return log;
const formattedTime = logParts.slice(0, 4).join(' ');
const status = logParts[5];
const mapping = statusMappings[status] || { status: status, startIndex: 6 };
const message = logParts.slice(mapping.startIndex).join(' ');
return `${formattedTime} [ ${mapping.status} ] - ${message}`;
}
function formatLogLines(logdata) {
const text = String(logdata || '').trim();
if (!text)
return { value: translateLogText('Log is empty.'), rows: 2 };
const loglines = text.split(/\n/).map(formatLogLine).filter(Boolean);
return { value: loglines.join('\n'), rows: loglines.length + 1 };
}
function formatLogError(err) {
const message = String(
(err && (err.stderr || err.message || err.stdout)) || err || translateLogText('Unknown error')
).trim().replace(/\n+/g, ' ');
return {
value: translateLogText('Unable to load log data:') + ' ' + message,
rows: 2
};
}
return view.extend({
async retrieveLog() {
const stat = await Promise.all([
@@ -58,14 +12,26 @@ return view.extend({
]);
const logger = stat[0] ? stat[0].path : stat[1] ? stat[1].path : null;
if (!logger)
return formatLogError(translateLogText('logread command not found'));
try {
return formatLogLines(await fs.exec_direct(logger, ['-e', 'tailscale']));
} catch (err) {
return formatLogError(err);
}
const logdata = await fs.exec_direct(logger, ['-e', 'tailscale']);
const statusMappings = {
'daemon.err': { status: 'StdErr', startIndex: 9 },
'daemon.notice': { status: 'Info', startIndex: 10 }
};
const loglines = logdata.trim().split(/\n/).map(function(log) {
const logParts = log.split(' ').filter(Boolean);
if (logParts.length >= 6) {
const formattedTime = `${logParts[1]} ${logParts[2]} - ${logParts[3]}`;
const status = logParts[5];
const mapping = statusMappings[status] || { status: status, startIndex: 9 };
const newStatus = mapping.status;
const startIndex = mapping.startIndex;
const message = logParts.slice(startIndex).join(' ');
return `${formattedTime} [ ${newStatus} ] - ${message}`;
} else {
return 'Log is empty.';
}
}).filter(Boolean);
return { value: loglines.join('\n'), rows: loglines.length + 1 };
},
async pollLog() {
@@ -1,695 +0,0 @@
/* SPDX-License-Identifier: GPL-3.0-only
*
* Copyright (C) 2026
*/
'use strict';
'require dom';
'require fs';
'require poll';
'require view';
var PROBE_MAX_ATTEMPTS = 5;
var PROBE_RETRY_DELAY_MS = 350;
var PEER_PAGE_SIZE_DEFAULT = 25;
var PEER_PAGE_SIZE_OPTIONS = [25, 50, 100, 0];
function formatLastSeen(value) {
if (!value)
return '-';
value = String(value);
if (value.indexOf('0001-01-01') === 0)
return '-';
var date = new Date(value);
if (isNaN(date.getTime()))
return value;
return date.toLocaleString();
}
function parseStatus(stdout) {
var losslessJson = String(stdout || '{}').replace(/("UserID"\s*:\s*)(\d+)/g, '$1"$2"');
var parsed = JSON.parse(losslessJson);
var peerEntries = parsed && parsed.Peer ? parsed.Peer : {};
var userEntries = parsed && parsed.User ? parsed.User : {};
var peers = [];
function appendPeer(peerId, peer, isSelf) {
peer = peer || {};
var ip = Array.isArray(peer.TailscaleIPs) ? (peer.TailscaleIPs[0] || '') : '';
var dnsName = String(peer.DNSName || '').replace(/\.$/, '');
var shortDnsName = dnsName ? dnsName.split('.')[0] : '';
var hostName = String(peer.HostName || '');
var displayName = (shortDnsName && hostName && shortDnsName !== hostName)
? shortDnsName + ' (' + hostName + ')'
: (shortDnsName || hostName || dnsName || ip || peerId);
var routes = Array.isArray(peer.PrimaryRoutes) ? peer.PrimaryRoutes.filter(Boolean) : [];
var userKey = peer.UserID != null ? String(peer.UserID) : '';
var user = userKey ? (userEntries[userKey] || {}) : {};
var userDisplayName = String(user.DisplayName || '').trim();
var userLoginName = String(user.LoginName || '').trim();
peers.push({
id: peerId,
name: displayName,
ip: ip,
userKey: userKey || 'unknown',
userName: userDisplayName || userLoginName || '',
userLoginName: userLoginName,
online: !!peer.Online,
lastSeen: formatLastSeen(peer.LastSeen),
exitNode: !!peer.ExitNodeOption,
routes: routes,
hasSubnetRoutes: routes.length > 0,
probeTarget: ip || hostName || dnsName || shortDnsName,
isSelf: !!isSelf
});
}
if (parsed && parsed.Self) {
var self = parsed.Self;
var selfId = self.ID || self.PublicKey || self.HostName || 'self';
appendPeer(selfId, self, true);
}
Object.keys(peerEntries).forEach(function(peerId) {
appendPeer(peerId, peerEntries[peerId], false);
});
peers.sort(function(a, b) {
return String(a.name || '').localeCompare(String(b.name || ''));
});
return peers;
}
function buildPeerGroups(peers) {
var groupMap = Object.create(null);
var groups = [];
peers.forEach(function(peer) {
var userKey = peer.userKey || 'unknown';
var group = groupMap[userKey];
if (!group) {
group = groupMap[userKey] = {
key: userKey,
name: peer.userName || _('Unknown user'),
loginName: peer.userLoginName || '',
peers: []
};
groups.push(group);
}
group.peers.push(peer);
});
groups.sort(function(a, b) {
return String(a.name || '').localeCompare(String(b.name || ''));
});
groups.forEach(function(group) {
group.peers.sort(function(a, b) {
return String(a.name || '').localeCompare(String(b.name || ''));
});
});
return groups;
}
function cloneGroupWithPeers(group, peers) {
return {
key: group.key,
name: group.name,
loginName: group.loginName,
peers: peers
};
}
function countPagePeers(groups) {
return groups.reduce(function(count, group) {
return count + group.peers.length;
}, 0);
}
function paginatePeerGroups(groups, pageSize, requestedPageIndex) {
var total = countPagePeers(groups);
var pages = [];
var currentGroups = [];
var currentCount = 0;
if (!total || pageSize === 0) {
pages = total ? [{ groups: groups, count: total }] : [];
} else {
var pushCurrent = function() {
if (!currentGroups.length)
return;
pages.push({
groups: currentGroups,
count: currentCount
});
currentGroups = [];
currentCount = 0;
};
groups.forEach(function(group) {
var peers = group.peers || [];
/* split oversized groups into dedicated pages; do not mix their tail with other users */
if (peers.length > pageSize) {
pushCurrent();
for (var i = 0; i < peers.length; i += pageSize) {
var chunk = peers.slice(i, i + pageSize);
pages.push({
groups: [cloneGroupWithPeers(group, chunk)],
count: chunk.length
});
}
return;
}
if (currentCount > 0 && currentCount + peers.length > pageSize)
pushCurrent();
currentGroups.push(group);
currentCount += peers.length;
});
pushCurrent();
}
var pageCount = Math.max(pages.length, 1);
var pageIndex = Math.min(Math.max(Number(requestedPageIndex) || 0, 0), pageCount - 1);
var start = 0;
for (var j = 0; j < pageIndex; j++)
start += pages[j].count;
return {
groups: pages[pageIndex] ? pages[pageIndex].groups : [],
pageIndex: pageIndex,
pageCount: pageCount,
pageSize: pageSize,
total: total,
start: total ? start + 1 : 0,
end: total ? start + (pages[pageIndex] ? pages[pageIndex].count : 0) : 0
};
}
function parseProbeResult(stdout, errorMessage) {
var fallback = {
ok: false,
path: 'failed',
summary: errorMessage || _('Probe failed'),
raw: ''
};
if (!stdout)
return fallback;
try {
var parsed = JSON.parse(String(stdout));
return parsed && typeof parsed === 'object' ? parsed : fallback;
} catch (e) {
fallback.summary = errorMessage || String(e.message || e);
return fallback;
}
}
function stripProbeSummary(path, summary) {
summary = String(summary || '').trim();
if (!summary)
return '';
if (path === 'direct')
return summary.replace(/^direct\b[\s:-]*/i, '');
if (path === 'derp')
return summary.replace(/^DERP\b[\s:-]*/i, '');
return summary;
}
function renderProbeResult(result) {
if (!result)
return E('span', { style: 'color:#94a3b8' }, _('Not probed'));
var path = result.path || 'unknown';
var label = path === 'direct' ? _('Direct')
: (path === 'derp' ? _('DERP')
: (path === 'failed' ? _('Failed') : _('Unknown')));
var color = path === 'direct' ? '#166534'
: (path === 'derp' ? '#a16207'
: (path === 'failed' ? '#b91c1c' : '#475569'));
var summary = stripProbeSummary(path, result.summary);
return E('span', { style: 'color:%s'.format(color) }, [
E('strong', {}, label),
summary ? ' - ' + summary : ''
]);
}
function appendProbeSummary(result, suffix) {
var next = {
ok: result && result.ok,
path: result && result.path,
latency_ms: result && result.latency_ms,
relay: result && result.relay,
raw: result && result.raw,
summary: result && result.summary ? String(result.summary) : ''
};
next.summary = next.summary
? next.summary + ' - ' + suffix
: suffix;
return next;
}
function sleep(ms) {
return new Promise(function(resolve) {
window.setTimeout(resolve, ms);
});
}
function renderBadge(text) {
return E('span', {
style: 'display:inline-block;margin:0 4px 4px 0;padding:1px 6px;border-radius:3px;background:#eef2f7;color:#334155;font-size:12px;line-height:1.4'
}, text);
}
function renderStatusLabel(online) {
return E('span', {
style: 'font-weight:600;color:%s'.format(online ? '#166534' : '#64748b')
}, online ? _('Online') : _('Offline'));
}
function renderRoleBadges(peer) {
var badges = [];
if (peer.exitNode)
badges.push(renderBadge(_('Exit node')));
if (peer.hasSubnetRoutes)
badges.push(renderBadge(_('Advertising subnets')));
return badges.length ? badges : '-';
}
function getScrollState() {
if (typeof document === 'undefined' || typeof window === 'undefined')
return null;
var scrollElement = document.querySelector('.main-right') || document.scrollingElement || document.documentElement;
return {
scrollElement: scrollElement,
scrollLeft: scrollElement ? scrollElement.scrollLeft || 0 : window.scrollX || 0,
scrollTop: scrollElement ? scrollElement.scrollTop || 0 : window.scrollY || 0,
windowScrollX: window.scrollX || 0,
windowScrollY: window.scrollY || 0
};
}
function restoreScrollState(state) {
if (!state || typeof window === 'undefined')
return;
var restore = function() {
var scrollElement = state.scrollElement;
if (scrollElement) {
scrollElement.scrollLeft = state.scrollLeft;
scrollElement.scrollTop = state.scrollTop;
} else if (window.scrollTo) {
window.scrollTo(state.windowScrollX, state.windowScrollY);
}
};
restore();
if (window.requestAnimationFrame)
window.requestAnimationFrame(restore);
else
setTimeout(restore, 0);
}
async function loadPeerState() {
try {
var res = await fs.exec('/usr/sbin/tailscale', ['status', '--json']);
var code = Number(res && res.code);
if (Number.isFinite(code) && code !== 0) {
var statusError = String((res && (res.stderr || res.stdout || res.message)) || _('Unable to load Tailscale peer status')).trim();
return {
ok: false,
peers: [],
error: statusError || _('Unable to load Tailscale peer status')
};
}
return {
ok: true,
peers: parseStatus(res.stdout),
error: ''
};
} catch (e) {
return {
ok: false,
peers: [],
error: String((e && (e.message || e.stderr || e.stdout)) || _('Unable to load Tailscale peer status'))
};
}
}
return view.extend({
load: loadPeerState,
render: function(data) {
var state = {
peers: (data && data.peers) || [],
error: (data && data.error) || '',
probeResults: Object.create(null),
probing: Object.create(null)
};
var filterMode = 'all';
var pageIndex = 0;
var pageSize = PEER_PAGE_SIZE_DEFAULT;
var tbody = E('tbody');
var statusBox = E('div');
var paginationBox = E('div');
var filterSelect = E('select', {
id: 'filterMode',
change: function(ev) {
filterMode = ev.target.value;
pageIndex = 0;
renderRows();
}
}, [
E('option', { value: 'all' }, _('All')),
E('option', { value: 'online' }, _('Online')),
E('option', { value: 'subnets' }, _('Advertising subnets'))
]);
var pageSizeSelect = E('select', {
id: 'pageSize',
change: function(ev) {
pageSize = Number(ev.target.value);
pageIndex = 0;
renderRows();
}
}, PEER_PAGE_SIZE_OPTIONS.map(function(size) {
return E('option', {
value: String(size),
selected: size === pageSize ? 'selected' : null
}, size ? String(size) : _('All peers'));
}));
function filterPeers(peers) {
if (filterMode === 'online')
return peers.filter(function(peer) { return peer.online; });
if (filterMode === 'subnets')
return peers.filter(function(peer) { return peer.hasSubnetRoutes; });
return peers;
}
function renderPagination(page) {
if (!page.total) {
dom.content(paginationBox, '');
return;
}
dom.content(paginationBox, E('div', {
class: 'peer-pagination-bar',
style: 'display:flex;align-items:center;justify-content:space-between;gap:12px;margin:10px 0 0 0;flex-wrap:wrap'
}, [
E('div', {
class: 'peer-pagination-summary',
style: 'display:flex;align-items:center;gap:10px;color:#64748b;flex:1 1 auto;min-width:220px;flex-wrap:wrap'
}, [
E('span', {}, _('Showing %d-%d of %d peers').format(page.start, page.end, page.total)),
E('span', {}, _('Page %d / %d').format(page.pageIndex + 1, page.pageCount))
]),
E('div', {
class: 'peer-pagination-controls',
style: 'display:flex;align-items:center;justify-content:flex-end;gap:10px;margin-left:auto;flex-wrap:wrap'
}, [
E('label', { for: 'pageSize' }, _('Items per page')),
pageSizeSelect,
E('button', {
type: 'button',
class: 'btn cbi-button',
disabled: page.pageIndex <= 0 ? 'disabled' : null,
click: function(ev) {
if (ev) {
ev.preventDefault();
ev.stopPropagation();
}
pageIndex = Math.max(pageIndex - 1, 0);
renderRows(true);
}
}, _('Previous')),
E('button', {
type: 'button',
class: 'btn cbi-button',
disabled: page.pageIndex >= page.pageCount - 1 ? 'disabled' : null,
click: function(ev) {
if (ev) {
ev.preventDefault();
ev.stopPropagation();
}
pageIndex = Math.min(pageIndex + 1, page.pageCount - 1);
renderRows(true);
}
}, _('Next'))
])
]));
}
function renderRows(keepScroll) {
var filtered = filterPeers(state.peers);
var page = paginatePeerGroups(buildPeerGroups(filtered), pageSize, pageIndex);
var rows;
var scrollState = keepScroll ? getScrollState() : null;
pageIndex = page.pageIndex;
renderPagination(page);
if (state.error) {
dom.content(statusBox, E('div', {
style: 'margin-bottom:10px;padding:8px 10px;border-left:3px solid #d97706;background:#fff7ed;color:#9a3412'
}, state.error));
} else {
dom.content(statusBox, '');
}
if (!filtered.length) {
rows = [
E('tr', { class: 'tr' },
E('td', {
class: 'td left',
colspan: '7'
}, state.peers.length ? _('No peers match the selected filter.') : _('No peers found'))
)
];
} else {
rows = [];
page.groups.forEach(function(group) {
rows.push(E('tr', { class: 'tr peer-group-header' },
E('td', {
class: 'td left',
colspan: '7',
style: 'background:#f1f5f9;border-left:4px solid #64748b;font-weight:600;padding-top:10px;padding-bottom:10px'
}, E('div', {
style: 'display:flex;align-items:baseline;gap:10px;flex-wrap:wrap'
}, [
E('span', {
class: 'peer-group-title',
style: 'font-size:16px;font-weight:700;color:#1e293b'
}, group.name),
group.loginName && group.loginName !== group.name
? E('span', { style: 'font-weight:400;color:#64748b;font-size:13px' }, group.loginName)
: '',
E('span', { style: 'font-weight:400;color:#64748b;font-size:13px' }, String(group.peers.length))
]))
));
group.peers.forEach(function(peer) {
var probeCell;
if (peer.isSelf) {
probeCell = E('span', { style: 'font-weight:600;color:#475569' }, _('Current device'));
} else {
var result = state.probeResults[peer.id];
var probing = !!state.probing[peer.id];
var button = E('button', {
type: 'button',
class: 'btn cbi-button cbi-button-action',
disabled: probing || !peer.online ? 'disabled' : null,
click: function(ev) {
if (ev) {
ev.preventDefault();
ev.stopPropagation();
}
return probePeer(peer);
}
}, probing ? _('Probing...') : _('Probe'));
var resultNode = !peer.online
? E('span', { style: 'color:#94a3b8' }, _('Offline peers cannot be probed'))
: (result ? renderProbeResult(result) : (probing
? E('span', { style: 'color:#64748b' }, _('Probing...'))
: renderProbeResult(result)));
probeCell = E('div', {
style: 'display:flex;align-items:center;gap:8px;flex-wrap:wrap'
}, [
button,
resultNode
]);
}
rows.push(E('tr', {
class: 'tr',
style: peer.online ? '' : 'opacity:0.62'
}, [
E('td', { class: 'td left' }, peer.name),
E('td', { class: 'td left' }, peer.ip || '-'),
E('td', { class: 'td left' }, renderStatusLabel(peer.online)),
E('td', { class: 'td left' }, peer.lastSeen || '-'),
E('td', { class: 'td left' }, renderRoleBadges(peer)),
E('td', { class: 'td left' }, peer.routes.length ? peer.routes.join(', ') : '-'),
E('td', { class: 'td left' }, probeCell)
]));
});
});
}
dom.content(tbody, rows);
restoreScrollState(scrollState);
}
async function refreshPeers() {
var next = await loadPeerState();
if (next.ok) {
state.peers = next.peers;
state.error = '';
} else {
state.peers = [];
state.error = next.error;
}
renderRows(true);
}
async function probeOnce(target) {
var res = await fs.exec('/usr/sbin/tailscale_peer_probe', [target]);
return parseProbeResult(
res && res.stdout,
res && (res.stderr || res.message || '')
);
}
async function probePeer(peer) {
var target = peer.probeTarget;
var attempt;
if (peer.isSelf || !peer.online)
return;
if (!target) {
state.probeResults[peer.id] = {
ok: false,
path: 'failed',
summary: _('No probe target available'),
raw: ''
};
renderRows(true);
return;
}
state.probing[peer.id] = true;
renderRows(true);
try {
for (attempt = 1; attempt <= PROBE_MAX_ATTEMPTS; attempt++) {
var result = await probeOnce(target);
if (result.path === 'derp' && attempt < PROBE_MAX_ATTEMPTS) {
state.probeResults[peer.id] = appendProbeSummary(
result,
_('Continuing probe %d/%d').format(attempt, PROBE_MAX_ATTEMPTS)
);
renderRows(true);
await sleep(PROBE_RETRY_DELAY_MS);
continue;
}
if (result.path === 'derp') {
state.probeResults[peer.id] = appendProbeSummary(
result,
_('%d probes; direct connection not established').format(PROBE_MAX_ATTEMPTS)
);
} else {
state.probeResults[peer.id] = result;
}
renderRows(true);
break;
}
} catch (e) {
state.probeResults[peer.id] = parseProbeResult('', String((e && (e.message || e.stderr || e.stdout)) || _('Probe failed')));
} finally {
state.probing[peer.id] = false;
renderRows(true);
}
}
poll.add(async function() {
await refreshPeers();
});
renderRows();
return E('div', { class: 'cbi-map' }, [
E('h2', { class: 'content' }, _('Tailscale Peers')),
E('div', { class: 'cbi-map-descr' }, _('View all peers and manually probe whether traffic is direct or relayed through DERP.')),
statusBox,
E('div', {
style: 'display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap'
}, [
E('label', { for: 'filterMode' }, _('Filter')),
filterSelect
]),
E('table', {
class: 'table',
style: 'table-layout:fixed;width:100%'
}, [
E('thead', {}, E('tr', { class: 'tr table-titles' }, [
E('th', { class: 'th left' }, _('Name')),
E('th', { class: 'th left' }, _('Tailnet IP')),
E('th', { class: 'th left' }, _('Status')),
E('th', { class: 'th left' }, _('Last Seen')),
E('th', { class: 'th left' }, _('Role')),
E('th', { class: 'th left' }, _('Advertised Subnets')),
E('th', { class: 'th left' }, _('Probe'))
])),
tbody
]),
paginationBox
]);
},
handleSaveApply: null,
handleSave: null,
handleReset: null
});
@@ -10,7 +10,6 @@
'require poll';
'require rpc';
'require uci';
'require ui';
'require view';
const callServiceList = rpc.declare({
@@ -20,61 +19,6 @@ const callServiceList = rpc.declare({
expect: { '': {} }
});
const callAdguardPreflight = rpc.declare({
object: 'luci.tailscale',
method: 'adguard_preflight',
params: [
'candidate',
'api_url',
'username',
'password_set',
'password',
'default_upstreams',
'tailnet_upstreams',
'health_domain',
'expected_ips'
],
expect: { '': {} },
reject: true
});
const callSecretStatus = rpc.declare({
object: 'luci.tailscale',
method: 'secret_status',
expect: { '': {} },
reject: true
});
const callSetSecrets = rpc.declare({
object: 'luci.tailscale',
method: 'set_secrets',
params: ['authkey_set', 'authkey', 'adguard_password_set', 'adguard_password', 'api_url', 'username', 'base_ref'],
expect: { '': {} },
reject: true
});
const callOpenclashBypassStatus = rpc.declare({
object: 'luci.tailscale',
method: 'openclash_bypass_status',
expect: { '': {} },
reject: true
});
const callPolicyRoutingStatus = rpc.declare({
object: 'luci.tailscale',
method: 'policy_routing_status',
expect: { '': {} },
reject: true
});
const ADGUARD_PREFLIGHT_CHECKS = [
{ key: 'adguard_process', label: _('AdGuard process') },
{ key: 'port_53_adguard', label: _('Port 53 is AdGuard') },
{ key: 'dhcp_advertises_lan_dns', label: _('LAN DHCP advertises this router as DNS') },
{ key: 'adguard_api', label: _('AdGuard API') },
{ key: 'health_check', label: _('Tailnet DNS health check') }
];
async function getInterfaceSubnets(interfaces = ['lan', 'wan']) {
const networks = await network.getNetworks();
return [...new Set(
@@ -99,7 +43,7 @@ async function getStatus() {
authURL: undefined,
displayName: undefined,
onlineExitNodes: [],
peers: []
subnetRoutes: []
};
const res = await callServiceList('tailscale');
try {
@@ -116,40 +60,10 @@ async function getStatus() {
status.authURL = tailscaleStatus.AuthURL;
status.displayName = (status.backendState === "Running") ? tailscaleStatus.User[tailscaleStatus.Self.UserID].DisplayName : undefined;
if (tailscaleStatus.Peer) {
status.peers = Object.values(tailscaleStatus.Peer)
.map(peer => {
const ip = Array.isArray(peer.TailscaleIPs) ? (peer.TailscaleIPs[0] || '') : '';
const dnsName = (peer.DNSName || '').replace(/\.$/, '');
const shortDnsName = dnsName.split('.', 1)[0] || '';
const hostName = peer.HostName || '';
const name = shortDnsName || hostName || dnsName || ip;
const routes = Array.isArray(peer.PrimaryRoutes) ? peer.PrimaryRoutes : [];
const hasSubnetRoutes = routes.length > 0;
const displayName = (shortDnsName && hostName && shortDnsName !== hostName)
? [shortDnsName, '(' + hostName + ')'].join(' ')
: (shortDnsName || hostName || dnsName || ip);
const onlineLabel = peer.Online ? _('Online') : _('Offline');
const label = [
displayName,
ip,
onlineLabel,
routes.join(', ')
].filter(Boolean).join(' ');
return {
name: name,
label: label,
displayName: displayName,
ip: ip,
onlineLabel: onlineLabel,
routes: routes,
hasSubnetRoutes: hasSubnetRoutes,
aliases: [hostName, dnsName, shortDnsName, ip].filter(Boolean)
};
})
.filter(peer => peer.name);
status.onlineExitNodes = Object.values(tailscaleStatus.Peer)
.flatMap(peer => (peer.ExitNodeOption && peer.Online) ? [peer.HostName] : []);
status.subnetRoutes = Object.values(tailscaleStatus.Peer)
.flatMap(peer => peer.PrimaryRoutes || []);
}
return status;
}
@@ -181,295 +95,38 @@ function renderLogin(loginStatus, authURL, displayName) {
return renderHTML;
}
function toList(value) {
if (!value)
return [];
if (Array.isArray(value))
return value;
return String(value).trim().split(/\s+/).filter(Boolean);
}
function renderCheck(value) {
if (value !== 'pass' && value !== 'fail')
return E('span', { style: 'color:#687586' }, _('Checking ...'));
const ok = value === 'pass';
return E('span', { style: 'color:' + (ok ? 'green' : 'red') }, ok ? _('Pass') : _('Fail'));
}
function adguardPreflightCellId(key) {
return 'adguard_preflight_' + key;
}
function buildAdguardPreflightRequest(values) {
return {
candidate: '1',
api_url: String(values.apiUrl || ''),
username: String(values.username || ''),
password_set: values.keepPassword ? '0' : '1',
password: values.keepPassword ? '' : String(values.password || ''),
default_upstreams: toList(values.defaultUpstreams).join('\n'),
tailnet_upstreams: toList(values.tailnetUpstreams).join('\n'),
health_domain: String(values.healthDomain || ''),
expected_ips: toList(values.expectedIps).join('\n')
};
}
function normalizeAdguardApiUrl(value) {
return String(value || '').replace(/\/+$/, '');
}
function adguardConfigChanged(candidate, persisted) {
const current = candidate || {};
const saved = persisted || {};
return normalizeAdguardApiUrl(current.api_url) !== normalizeAdguardApiUrl(saved.api_url) ||
String(current.username || '') !== String(saved.username || '') ||
String(current.default_upstreams || '') !== String(saved.default_upstreams || '') ||
String(current.tailnet_upstreams || '') !== String(saved.tailnet_upstreams || '') ||
String(current.health_domain || '') !== String(saved.health_domain || '') ||
String(current.expected_ips || '') !== String(saved.expected_ips || '');
}
function adguardBindingChanged(candidate, persisted) {
return normalizeAdguardApiUrl(candidate && candidate.api_url) !== normalizeAdguardApiUrl(persisted && persisted.api_url) ||
String(candidate && candidate.username || '') !== String(persisted && persisted.username || '');
}
function buildSecretUpdateRequest(values) {
const authkey = String(values.authkey || '').trim();
const adguardPassword = values.adguardPassword == null ? '' : String(values.adguardPassword);
return {
authkey_set: authkey ? '1' : '0',
authkey: authkey,
adguard_password_set: adguardPassword ? '1' : '0',
adguard_password: adguardPassword,
api_url: String(values.adguardApiUrl || '').trim(),
username: String(values.adguardUsername || '').trim(),
base_ref: String(values.baseRef || '').trim()
};
}
function saveMapWithSecrets(map, cb, silent, stageSecrets, afterSave) {
let stagedSecrets;
map.checkDepends();
return map.parse()
.then(cb)
.then(stageSecrets)
.then(function(staged) {
stagedSecrets = staged;
return map.data.save();
})
.then(map.load.bind(map))
.then(function() {
return afterSave(stagedSecrets);
})
.catch(function(e) {
if (!silent) {
ui.showModal(_('Save error'), [
E('p', {}, [ _('An error occurred while saving the form:') ]),
E('p', {}, [ E('em', { style: 'white-space:pre-wrap' }, [ e.message ]) ]),
E('div', { class: 'right' }, [
E('button', { class: 'cbi-button', click: ui.hideModal }, [ _('Dismiss') ])
])
]);
}
return Promise.reject(e);
})
.then(map.renderContents.bind(map));
}
async function fetchAdguardPreflightStatus(request) {
const input = request || {
candidate: '0',
api_url: '',
username: '',
password_set: '0',
password: '',
default_upstreams: '',
tailnet_upstreams: '',
health_domain: '',
expected_ips: ''
};
try {
return await callAdguardPreflight(
input.candidate,
input.api_url,
input.username,
input.password_set,
input.password,
input.default_upstreams,
input.tailnet_upstreams,
input.health_domain,
input.expected_ips
);
} catch (e) {
const error = e && (e.message || e.stderr || e) || 'preflight command failed';
return {
ready: 'fail',
error: String(error).replace(/\n/g, ' ')
};
function updateVersionDisplay(data) {
var curEl = document.getElementById('ts_current_ver');
var latEl = document.getElementById('ts_latest_ver');
var updBtn = document.getElementById('ts_update_btn');
if (curEl) curEl.textContent = data.current || 'unknown';
if (latEl) latEl.textContent = data.latest || 'unknown';
if (updBtn) {
if (data.current && data.latest && data.current === data.latest) {
updBtn.disabled = true;
updBtn.textContent = _('Already Latest');
} else if (data.latest && data.latest !== 'unknown') {
updBtn.disabled = false;
updBtn.textContent = _('Update Now');
}
}
}
async function writeAdguardDnsSwitchEnabled(section_id, value, candidateRequest, persistedRequest) {
const persistedEnabled = persistedRequest && persistedRequest.enabled === '1';
const shouldPreflight = value === '1' && (
!persistedEnabled ||
candidateRequest.password_set === '1' ||
adguardConfigChanged(candidateRequest, persistedRequest)
);
if (shouldPreflight) {
if (persistedEnabled && adguardBindingChanged(candidateRequest, persistedRequest) && !String(candidateRequest.password || ''))
throw new Error(_('Re-enter the AdGuard password after changing the API URL or username.'));
const status = await fetchAdguardPreflightStatus(candidateRequest);
if (status.ready !== 'pass')
throw new Error(_('Only enable when all status checks pass.'));
}
return uci.set('tailscale', section_id, 'adguard_dns_switch_enabled', value);
}
async function refreshAdguardPreflightStatus() {
const status = await fetchAdguardPreflightStatus();
ADGUARD_PREFLIGHT_CHECKS.forEach(function(check) {
const cell = document.getElementById(adguardPreflightCellId(check.key));
if (!cell)
return;
cell.innerHTML = '';
cell.appendChild(renderCheck(status[check.key] || 'fail'));
});
}
function renderOpenclashBypassStatus(status) {
const labels = {
active: _('Enabled; 4 bypass rules are active'),
waiting: _('Enabled; waiting for OpenClash nftables chains'),
disabled: _('Disabled; Tailscale traffic is handled by OpenClash'),
absent: _('OpenClash is not installed; bypass is not required'),
unsupported: _('Unsupported; firewall4/nftables is required'),
error: _('Configuration error')
};
return labels[status && status.state] || _('Unknown status');
}
async function refreshOpenclashBypassStatus() {
const node = document.getElementById('openclash_bypass_status');
if (!node)
return;
try {
const status = await callOpenclashBypassStatus();
node.textContent = renderOpenclashBypassStatus(status);
} catch (error) {
node.textContent = _('Unable to read OpenClash bypass status.');
}
}
function renderPolicyRoutingStatus(status) {
if (status && status.state === 'active' && !status.mwan3_present)
return _('Enabled; mwan3 is not installed');
if (status && status.state === 'active' && !status.mwan3_earlier_mark_rule)
return _('Enabled; no earlier mwan3-marked rule was detected');
const labels = {
active: _('Enabled; Tailscale routes take precedence over mwan3'),
waiting: _('Enabled; waiting for the managed policy rule'),
disabled: _('Disabled; mwan3 routing is unchanged'),
blocked_default_route: _('Blocked; table 52 contains a default route'),
blocked_mwan3_priority: _('Blocked; a mwan3 rule has higher priority than 1000'),
blocked_priority_conflict: _('Blocked; priority 1000 is already in use'),
blocked_ownership: _('Blocked; an existing rule is not managed by this app'),
error: _('Configuration error')
};
return labels[status && status.state] || _('Unknown status');
}
async function refreshPolicyRoutingStatus() {
const node = document.getElementById('policy_routing_status');
if (!node)
return;
try {
const status = await callPolicyRoutingStatus();
node.textContent = renderPolicyRoutingStatus(status);
} catch (error) {
node.textContent = _('Unable to read Tailscale policy-routing status.');
}
}
function hasFormListValue(option, section_id) {
return toList(option.formvalue(section_id)).map(function(value) {
return String(value || '').trim();
}).filter(Boolean).length > 0;
}
function getFirewallZones() {
const zones = uci.sections('firewall', 'zone')
.map(function(zone) { return zone.name; })
.filter(Boolean);
return zones.length ? zones : ['wan'];
}
return view.extend({
load() {
return callSecretStatus().then(function(secretStatus) {
return Promise.all([
uci.load('tailscale'),
uci.load('firewall'),
getStatus(),
getInterfaceSubnets(),
uci.load('tailscale_openclash'),
uci.load('tailscale_policy_routing')
]).then(function(data) {
return [
data[0],
data[1],
data[2],
data[3],
data[4],
secretStatus,
data[5]
];
});
});
return Promise.all([
uci.load('tailscale'),
getStatus(),
getInterfaceSubnets()
]);
},
render(data) {
let m, s, o;
const statusData = data[2];
const interfaceSubnets = data[3];
const firewallZones = getFirewallZones();
const statusData = data[1];
const interfaceSubnets = data[2];
const onlineExitNodes = statusData.onlineExitNodes;
const peers = statusData.peers;
const secretStatus = data[5] || {};
let hasAuthKey = secretStatus.authkey_set === '1';
const savedKeepalivePeers = toList(uci.get('tailscale', 'settings', 'keepalive_peers'));
let hasAdguardPassword = secretStatus.adguard_password_set === '1';
let saveInFlight = null;
let adguardPasswordOption, authKeyOption;
let persistedAdguardConfig;
function readPersistedAdguardConfig() {
const request = buildAdguardPreflightRequest({
apiUrl: uci.get('tailscale', 'settings', 'adguard_api_url'),
username: uci.get('tailscale', 'settings', 'adguard_username'),
password: '',
keepPassword: true,
defaultUpstreams: uci.get('tailscale', 'settings', 'adguard_default_upstreams'),
tailnetUpstreams: uci.get('tailscale', 'settings', 'adguard_tailnet_upstreams'),
healthDomain: uci.get('tailscale', 'settings', 'adguard_health_domain'),
expectedIps: uci.get('tailscale', 'settings', 'adguard_health_expected_ips')
});
request.enabled = uci.get('tailscale', 'settings', 'adguard_dns_switch_enabled') === '1' ? '1' : '0';
return request;
}
persistedAdguardConfig = readPersistedAdguardConfig();
const subnetRoutes = statusData.subnetRoutes;
m = new form.Map('tailscale', _('Tailscale'), _('Tailscale is a cross-platform and easy to use virtual LAN.'));
@@ -515,18 +172,6 @@ return view.extend({
o.default = '41641';
o.rmempty = false;
o = s.taboption('basic', form.Flag, 'allow_wan_direct', _('Allow WAN Direct'), _('Allow inbound UDP traffic from WAN to the local Tailscale listen port so remote peers can establish direct connections without first using DERP.'));
o.default = o.disabled;
o.rmempty = false;
o = s.taboption('basic', form.DynamicList, 'wan_direct_zones', _('WAN Direct Source Zones'), _('Firewall source zones allowed to reach the local Tailscale listen port when WAN direct is enabled.'));
firewallZones.forEach(function(zone) {
o.value(zone, zone);
});
o.default = 'wan';
o.depends('allow_wan_direct', '1');
o.rmempty = false;
o = s.taboption('basic', form.Value, 'config_path', _('Workdir'), _('The working directory contains config files, audit logs, and runtime info.'));
o.default = '/etc/tailscale';
o.rmempty = false;
@@ -551,27 +196,6 @@ return view.extend({
o.default = o.disabled;
o.rmempty = false;
o = s.taboption('advance', form.Flag, 'mwan3_table52_precedence', _('Prioritize Tailscale Routes Before mwan3'),
_('Give routes already present in Tailscale table 52 priority over mwan3-marked traffic. This fixes LAN clients being sent to a WAN instead of a Tailnet or routed subnet. Public destinations not present in table 52 continue through mwan3. Disabled leaves mwan3 precedence unchanged. Verify from a real LAN client or with an equivalent mwan3 fwmark; the router\'s unmarked ip route get alone is insufficient. The feature remains blocked while table 52 has a default route, such as when using an exit node.'));
o.default = o.disabled;
o.rmempty = false;
o.cfgvalue = function() {
return uci.get('tailscale_policy_routing', 'settings', 'enabled') || '0';
};
o.write = function(section_id, value) {
return uci.set('tailscale_policy_routing', 'settings', 'enabled', value);
};
o.remove = function() {
return uci.set('tailscale_policy_routing', 'settings', 'enabled', '0');
};
o = s.taboption('advance', form.DummyValue, '_policy_routing_status', _('Policy Routing Status'));
o.rawhtml = true;
o.cfgvalue = function() { return ''; };
o.renderWidget = function() {
return E('span', { id: 'policy_routing_status' }, _('Checking ...'));
};
o = s.taboption('advance', form.Value, 'hostname', _('Device Name'), _("Leave blank to use the device's hostname."));
o.default = '';
o.rmempty = true;
@@ -612,6 +236,19 @@ return view.extend({
o.depends('accept_routes', '1');
o.rmempty = false;
o = s.taboption('advance', form.DynamicList, 'subnet_routes', _('Subnet Routes'), _('Select subnet routes advertised by other nodes in Tailscale network.'));
if (subnetRoutes.length > 0) {
subnetRoutes.forEach(function(route) {
o.value(route, route);
});
} else {
o.value('', _('No Available Subnet Routes'));
o.readonly = true;
}
o.default = '';
o.depends('disable_snat_subnet_routes', '1');
o.rmempty = true;
o = s.taboption('advance', form.MultiValue, 'access', _('Access Control'));
o.value('ts_ac_lan', _('Tailscale access LAN'));
o.value('ts_ac_wan', _('Tailscale access WAN'));
@@ -620,271 +257,6 @@ return view.extend({
o.default = "ts_ac_lan ts_ac_wan lan_ac_ts";
o.rmempty = true;
s.tab('keepalive', _('Keepalive'));
o = s.taboption('keepalive', form.Flag, 'keepalive_enabled', _('Peer Keepalive'), _('Periodically send lightweight Tailscale pings to selected peers.'));
o.default = o.disabled;
o.rmempty = false;
const keepalivePeerChoices = [];
const keepalivePeerChoiceNames = {};
const keepalivePeerAliases = {};
const keepalivePeersByName = {};
peers.forEach(function(peer) {
keepalivePeersByName[peer.name] = peer;
(peer.aliases || []).forEach(function(alias) {
keepalivePeerAliases[alias] = peer.name;
});
});
const selectedKeepalivePeers = {};
savedKeepalivePeers.forEach(function(peer) {
const matchedName = keepalivePeerAliases[peer];
if (matchedName && keepalivePeersByName[matchedName] && keepalivePeersByName[matchedName].hasSubnetRoutes)
selectedKeepalivePeers[matchedName] = true;
else
selectedKeepalivePeers[peer] = true;
});
peers.forEach(function(peer) {
if (!peer.hasSubnetRoutes)
return;
keepalivePeerChoiceNames[peer.name] = true;
keepalivePeerChoices.push({
value: peer.name,
peer: peer
});
});
savedKeepalivePeers.forEach(function(peer) {
const matchedName = keepalivePeerAliases[peer];
const matchedPeer = matchedName ? keepalivePeersByName[matchedName] : null;
if (matchedPeer && matchedPeer.hasSubnetRoutes)
return;
if (keepalivePeerChoiceNames[peer])
return;
keepalivePeerChoiceNames[peer] = true;
if (matchedPeer) {
keepalivePeerChoices.push({
value: peer,
peer: matchedPeer,
warning: _('No subnet routes')
});
}
else {
keepalivePeerChoices.push({
value: peer,
peer: {
displayName: peer,
ip: '',
onlineLabel: '',
routes: []
},
warning: _('Not found')
});
}
});
const KeepalivePeersValue = form.Value.extend({
cfgvalue: function() {
return savedKeepalivePeers;
},
formvalue: function(section_id) {
const node = document.getElementById(this.cbid(section_id));
const values = node ? Array.from(node.querySelectorAll('input[type="checkbox"]:checked')).map(function(input) {
return input.value;
}) : [];
return values.length ? values : '';
},
renderWidget: function(section_id) {
const disabled = (this.readonly != null) ? this.readonly : this.map.readonly;
if (keepalivePeerChoices.length === 0) {
return E('div', {
id: this.cbid(section_id),
class: 'keepalive-peer-list'
}, E('em', _('No Available Peers')));
}
return E('div', {
id: this.cbid(section_id),
class: 'keepalive-peer-list',
style: 'display:grid;gap:6px;max-width:680px;width:100%'
}, keepalivePeerChoices.map(function(choice, index) {
const peer = choice.peer;
const meta = [peer.ip, peer.onlineLabel].filter(Boolean).join(' / ');
const checkboxId = '%s.%d'.format(this.cbid(section_id), index);
const routes = (peer.routes || []).length ? '%s: %s'.format(_('Subnets'), peer.routes.join(', ')) : '';
return E('label', {
'for': checkboxId,
class: 'keepalive-peer-row',
style: 'display:grid;grid-template-columns:minmax(320px,1fr) minmax(140px,260px);gap:10px;align-items:center;min-height:42px;padding:5px 10px;border:1px solid #d8dee5;border-radius:4px;box-sizing:border-box;cursor:pointer'
}, [
E('span', { class: 'keepalive-peer-main', style: 'display:flex;align-items:center;gap:10px;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis' }, [
E('span', { class: 'keepalive-peer-check', style: 'display:flex;align-items:center;justify-content:center;line-height:0;flex:0 0 24px' }, E('input', {
id: checkboxId,
type: 'checkbox',
value: choice.value,
checked: selectedKeepalivePeers[choice.value] ? 'checked' : null,
disabled: disabled ? 'disabled' : null,
style: 'margin:0'
})),
E('strong', {}, peer.displayName || choice.value),
meta ? E('span', { style: 'color:#687586;font-size:12px;overflow:hidden;text-overflow:ellipsis' }, meta) : ''
]),
E('span', { style: 'color:#687586;font-size:12px;line-height:1.3;text-align:right;white-space:normal;overflow-wrap:anywhere;max-width:260px' }, [
routes,
choice.warning ? E('span', { style: routes ? 'color:#b7791f;margin-left:8px' : 'color:#b7791f' }, choice.warning) : ''
])
]);
}, this));
}
});
o = s.taboption('keepalive', KeepalivePeersValue, 'keepalive_peers', _('Keepalive Peers'), _('Only peers advertising subnet routes are shown. Selected peers are periodically pinged to keep cross-subnet paths active.'));
o.rmempty = true;
o.depends('keepalive_enabled', '1');
o = s.taboption('keepalive', form.Value, 'keepalive_interval', _('Keepalive Interval'), _('Seconds between keepalive probes.'));
o.datatype = 'uinteger';
o.default = '20';
o.depends('keepalive_enabled', '1');
o.rmempty = false;
o = s.taboption('keepalive', form.Value, 'keepalive_failure_log_interval', _('Failure Log Interval'), _('Seconds between repeated failure log messages for the same peer.'));
o.datatype = 'uinteger';
o.default = '300';
o.depends('keepalive_enabled', '1');
o.rmempty = false;
s.tab('adguard_dns', _('AdGuard DNS'));
let adguardApiUrlOption, adguardUsernameOption;
let adguardDefaultUpstreamsOption, adguardTailnetUpstreamsOption, adguardHealthDomainOption, adguardHealthExpectedIpsOption;
adguardApiUrlOption = s.taboption('adguard_dns', form.Value, 'adguard_api_url', _('AdGuard API URL'));
adguardApiUrlOption.default = 'http://127.0.0.1:3000';
adguardApiUrlOption.rmempty = false;
adguardUsernameOption = s.taboption('adguard_dns', form.Value, 'adguard_username', _('AdGuard Username'));
adguardUsernameOption.default = '';
adguardUsernameOption.rmempty = true;
adguardPasswordOption = s.taboption('adguard_dns', form.Value, 'adguard_password', _('AdGuard Password'));
adguardPasswordOption.password = true;
adguardPasswordOption.default = '';
adguardPasswordOption.rmempty = true;
adguardPasswordOption.placeholder = hasAdguardPassword ? _('Configured') : '';
adguardPasswordOption.description = _('Leave blank to keep the existing AdGuard password; enter a new value to replace it.');
adguardPasswordOption.cfgvalue = function() {
return '';
};
adguardPasswordOption.write = function() {};
adguardPasswordOption.remove = function() {};
o = s.taboption('adguard_dns', form.DummyValue, '_adguard_dns_status', _('Status'));
o.renderWidget = function() {
return E('div', { class: 'table' }, ADGUARD_PREFLIGHT_CHECKS.map(function(check) {
return E('div', { class: 'tr' }, [
E('div', { class: 'td left' }, check.label),
E('div', { class: 'td', id: adguardPreflightCellId(check.key) }, renderCheck())
]);
}));
};
o = s.taboption('adguard_dns', form.Flag, 'adguard_dns_switch_enabled', _('Enable AdGuard DNS Auto Switch'), _('Only enable when all status checks pass.'));
o.default = o.disabled;
o.rmempty = false;
o.write = function(section_id, value) {
const password = String(adguardPasswordOption.formvalue(section_id) || '');
const candidateRequest = buildAdguardPreflightRequest({
apiUrl: String(adguardApiUrlOption.formvalue(section_id) || '').trim(),
username: String(adguardUsernameOption.formvalue(section_id) || '').trim(),
password: password,
keepPassword: !password && hasAdguardPassword,
defaultUpstreams: adguardDefaultUpstreamsOption.formvalue(section_id),
tailnetUpstreams: adguardTailnetUpstreamsOption.formvalue(section_id),
healthDomain: String(adguardHealthDomainOption.formvalue(section_id) || '').trim(),
expectedIps: adguardHealthExpectedIpsOption.formvalue(section_id)
});
return writeAdguardDnsSwitchEnabled(section_id, value, candidateRequest, persistedAdguardConfig);
};
o.validate = function(section_id, value) {
if (value !== '1')
return true;
if (!String(adguardHealthDomainOption.formvalue(section_id) || '').trim())
return _('Health Check Domain is required before enabling AdGuard DNS auto switch.');
if (!hasFormListValue(adguardHealthExpectedIpsOption, section_id))
return _('At least one Expected Internal IP is required before enabling AdGuard DNS auto switch.');
if (!hasFormListValue(adguardDefaultUpstreamsOption, section_id))
return _('At least one Default Upstream is required before enabling AdGuard DNS auto switch.');
if (!hasFormListValue(adguardTailnetUpstreamsOption, section_id))
return _('At least one Tailnet Upstream is required before enabling AdGuard DNS auto switch.');
return true;
};
adguardDefaultUpstreamsOption = s.taboption('adguard_dns', form.DynamicList, 'adguard_default_upstreams', _('Default Upstreams'), _('Used when Tailnet DNS is unhealthy.'));
adguardDefaultUpstreamsOption.default = '';
adguardDefaultUpstreamsOption.rmempty = true;
adguardTailnetUpstreamsOption = s.taboption('adguard_dns', form.DynamicList, 'adguard_tailnet_upstreams', _('Tailnet Upstreams'), _('Added when Tailnet DNS is healthy.'));
adguardTailnetUpstreamsOption.default = '';
adguardTailnetUpstreamsOption.rmempty = true;
adguardHealthDomainOption = s.taboption('adguard_dns', form.Value, 'adguard_health_domain', _('Health Check Domain'), _('Resolved through Tailscale DNS 100.100.100.100. The result must match one of the expected internal IPs.'));
adguardHealthDomainOption.rmempty = true;
adguardHealthExpectedIpsOption = s.taboption('adguard_dns', form.DynamicList, 'adguard_health_expected_ips', _('Expected Internal IPs'));
adguardHealthExpectedIpsOption.default = '';
adguardHealthExpectedIpsOption.rmempty = true;
o = s.taboption('adguard_dns', form.Value, 'adguard_check_interval', _('Check Interval'));
o.datatype = 'uinteger';
o.default = '10';
o.rmempty = false;
o = s.taboption('adguard_dns', form.Value, 'adguard_success_threshold', _('Success Threshold'));
o.datatype = 'uinteger';
o.default = '2';
o.rmempty = false;
o = s.taboption('adguard_dns', form.Value, 'adguard_failure_threshold', _('Failure Threshold'));
o.datatype = 'uinteger';
o.default = '2';
o.rmempty = false;
s.tab('openclash', _('OpenClash'));
o = s.taboption('openclash', form.Flag, 'openclash_bypass_enabled', _('Protect Tailscale Traffic (Bypass OpenClash)'),
_('Keep Tailscale control connections, direct connections, Tailnet DNS, and subnet traffic outside OpenClash. When disabled, this traffic is handled by OpenClash; node connectivity, direct paths, subnet access, and internal DNS are no longer protected by this feature. Keep this enabled while using OpenClash.'));
o.default = o.enabled;
o.rmempty = false;
o.cfgvalue = function() {
return uci.get('tailscale_openclash', 'settings', 'enabled') || '1';
};
o.write = function(section_id, value) {
return uci.set('tailscale_openclash', 'settings', 'enabled', value);
};
o.remove = function() {
return uci.set('tailscale_openclash', 'settings', 'enabled', '0');
};
o = s.taboption('openclash', form.DummyValue, '_openclash_bypass_status', _('Status'));
o.rawhtml = true;
o.cfgvalue = function() { return ''; };
o.renderWidget = function() {
return E('span', { id: 'openclash_bypass_status' }, _('Checking ...'));
};
s.tab('extra', _('Extra Settings'));
o = s.taboption('extra', form.DynamicList, 'flags', _('Additional Flags'),
@@ -904,79 +276,105 @@ return view.extend({
o.default = '';
o.rmempty = true;
authKeyOption = s.option(form.Value, 'authkey', _('Auth Key'));
authKeyOption.password = true;
authKeyOption.default = '';
authKeyOption.rmempty = true;
authKeyOption.placeholder = hasAuthKey ? _('Configured') : '';
authKeyOption.description = _('Used once for first-time enrollment or explicit re-enrollment, then removed after a successful connection. Leave blank for normal restarts.');
authKeyOption.cfgvalue = function() {
return '';
};
authKeyOption.write = function() {};
authKeyOption.remove = function() {};
o = s.option(form.Value, 'authKey', _('Auth Key'));
o.default = '';
o.rmempty = true;
m.save = function(cb, silent) {
if (saveInFlight)
return saveInFlight;
const map = this;
saveInFlight = saveMapWithSecrets(map, cb, silent, function() {
const request = Object.freeze(buildSecretUpdateRequest({
authkey: authKeyOption.formvalue('settings'),
adguardPassword: adguardPasswordOption.formvalue('settings'),
adguardApiUrl: adguardApiUrlOption.formvalue('settings'),
adguardUsername: adguardUsernameOption.formvalue('settings'),
baseRef: uci.get('tailscale', 'settings', 'secrets_ref')
}));
if (request.authkey_set !== '1' && request.adguard_password_set !== '1')
return { request: request, ref: request.base_ref };
return callSetSecrets(
request.authkey_set,
request.authkey,
request.adguard_password_set,
request.adguard_password,
request.api_url,
request.username,
request.base_ref
).then(function(result) {
const ref = String(result && result.ref || '');
if (!result || result.code !== 0 || !/^[A-Za-z0-9_.-]+$/.test(ref))
throw new Error(_('Failed to stage protected credentials.'));
uci.set('tailscale', 'settings', 'secrets_ref', ref);
return { request: request, ref: ref };
});
}, function(staged) {
const request = staged.request;
const updateStatus = function() {
if (request.authkey_set === '1') {
hasAuthKey = true;
authKeyOption.placeholder = _('Configured');
}
if (request.adguard_password_set === '1') {
hasAdguardPassword = true;
adguardPasswordOption.placeholder = _('Configured');
}
persistedAdguardConfig = readPersistedAdguardConfig();
};
updateStatus();
}).then(function(result) {
window.setTimeout(refreshOpenclashBypassStatus, 0);
window.setTimeout(refreshPolicyRoutingStatus, 0);
return result;
}).finally(function() {
saveInFlight = null;
});
return saveInFlight;
/* Core Update Section */
s = m.section(form.TypedSection);
s.anonymous = true;
s.title = _('Core Update');
s.description = _('Check and update the Tailscale binary to the latest version.');
s.render = function() {
return E('div', { class: 'cbi-section' }, [
E('h3', {}, _('Core Update')),
E('div', { class: 'cbi-value', style: 'margin-bottom: 10px' }, [
E('label', { class: 'cbi-value-title' }, _('Current Version')),
E('div', { class: 'cbi-value-field' }, [
E('span', { id: 'ts_current_ver' }, _('Checking...'))
])
]),
E('div', { class: 'cbi-value', style: 'margin-bottom: 10px' }, [
E('label', { class: 'cbi-value-title' }, _('Latest Version')),
E('div', { class: 'cbi-value-field' }, [
E('span', { id: 'ts_latest_ver' }, _('Checking...'))
])
]),
E('div', { class: 'cbi-value' }, [
E('label', { class: 'cbi-value-title' }, ''),
E('div', { class: 'cbi-value-field' }, [
E('button', {
class: 'btn cbi-button cbi-button-action',
id: 'ts_check_btn',
click: async function(ev) {
ev.preventDefault();
ev.stopPropagation();
var btn = ev.target;
btn.disabled = true;
btn.textContent = _('Checking...');
try {
var res = await fs.exec('/usr/sbin/tailscale_helper', ['check_update']);
if (res.stdout) {
var data = JSON.parse(res.stdout.trim());
updateVersionDisplay(data);
if (data.error) {
ui.addNotification(null, E('p', {}, data.error));
}
}
} catch(e) {
ui.addNotification(null, E('p', {}, e.message));
}
btn.disabled = false;
btn.textContent = _('Check Update');
}
}, _('Check Update')),
'\u00a0\u00a0',
E('button', {
class: 'btn cbi-button cbi-button-apply',
id: 'ts_update_btn',
click: async function(ev) {
ev.preventDefault();
ev.stopPropagation();
var btn = ev.target;
btn.disabled = true;
btn.textContent = _('Updating...');
try {
var res = await fs.exec('/usr/sbin/tailscale_helper', ['do_update']);
if (res.stdout) {
var data = JSON.parse(res.stdout.trim());
if (data.success) {
updateVersionDisplay({current: data.version, latest: data.version});
ui.addNotification(null, E('p', { style: 'color:green' },
_('Updated successfully to %s').format(data.version)));
} else if (data.error) {
ui.addNotification(null, E('p', {}, _('Update failed: %s').format(data.error)));
btn.disabled = false;
btn.textContent = _('Update Now');
}
}
} catch(e) {
ui.addNotification(null, E('p', {}, _('Update failed: %s').format(e.message)));
btn.disabled = false;
btn.textContent = _('Update Now');
}
}
}, _('Update Now'))
])
])
]);
};
return Promise.resolve(m.render()).then(function(node) {
window.setTimeout(refreshAdguardPreflightStatus, 0);
window.setTimeout(refreshOpenclashBypassStatus, 0);
window.setTimeout(refreshPolicyRoutingStatus, 0);
return node;
});
/* Auto check version on page load */
setTimeout(async function() {
try {
var res = await fs.exec('/usr/sbin/tailscale_helper', ['check_update']);
if (res.stdout) {
var data = JSON.parse(res.stdout.trim());
updateVersionDisplay(data);
}
} catch(e) {}
}, 1000);
return m.render();
}
});
+12 -392
View File
@@ -37,14 +37,6 @@ msgstr ""
msgid "Auth Key"
msgstr ""
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:267
msgid "Configured"
msgstr ""
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:268
msgid "Used once for first-time enrollment or explicit re-enrollment, then removed after a successful connection. Leave blank for normal restarts."
msgstr ""
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:248
msgid "Available flags"
msgstr ""
@@ -62,10 +54,6 @@ msgstr ""
msgid "Custom Server Settings"
msgstr ""
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:529
msgid "Current device"
msgstr ""
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:182
msgid "Device Name"
msgstr ""
@@ -119,126 +107,6 @@ msgstr ""
msgid "Interface Info"
msgstr ""
#: luci-app-tailscale/root/usr/share/luci/menu.d/luci-app-tailscale.json:30
msgid "Peers"
msgstr ""
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:216
msgid "Tailscale Peers"
msgstr ""
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:217
msgid "View all peers and manually probe whether traffic is direct or relayed through DERP."
msgstr ""
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:170
msgid "Unable to load Tailscale peer status"
msgstr ""
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:151
msgid "No peers match the selected filter."
msgstr ""
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:151
msgid "No peers found"
msgstr ""
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:191
msgid "No probe target available"
msgstr ""
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:55
msgid "Probe failed"
msgstr ""
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:74
msgid "Not probed"
msgstr ""
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:84
msgid "Probing..."
msgstr ""
msgid "Continuing probe %d/%d"
msgstr ""
msgid "%d probes; direct connection not established"
msgstr ""
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:85
msgid "Probe"
msgstr ""
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:62
msgid "Direct"
msgstr ""
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:63
msgid "DERP"
msgstr ""
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:64
msgid "Failed"
msgstr ""
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:64
msgid "Unknown"
msgstr ""
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:133
msgid "Filter"
msgstr ""
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:142
msgid "All"
msgstr ""
msgid "Items per page"
msgstr ""
msgid "All peers"
msgstr ""
msgid "Showing %d-%d of %d peers"
msgstr ""
msgid "Page %d / %d"
msgstr ""
msgid "Previous"
msgstr ""
msgid "Next"
msgstr ""
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:183
msgid "Name"
msgstr ""
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:184
msgid "Tailnet IP"
msgstr ""
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:186
msgid "Last Seen"
msgstr ""
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:187
msgid "Role"
msgstr ""
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:95
msgid "Exit node"
msgstr ""
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:98
msgid "Advertising subnets"
msgstr ""
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:188
msgid "Advertised Subnets"
msgstr ""
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/interface.js:69
msgid "Interface Name"
msgstr ""
@@ -298,6 +166,10 @@ msgstr ""
msgid "No Available Exit Nodes"
msgstr ""
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:228
msgid "No Available Subnet Routes"
msgstr ""
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/interface.js:61
msgid "No interface online."
msgstr ""
@@ -334,6 +206,10 @@ msgstr ""
msgid "Select an online machine name to use as an exit node."
msgstr ""
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:222
msgid "Select subnet routes advertised by other nodes in Tailscale network."
msgstr ""
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:258
msgid "Server Address"
msgstr ""
@@ -342,18 +218,6 @@ msgstr ""
msgid "Set the Tailscale port number."
msgstr ""
msgid "Allow WAN Direct"
msgstr ""
msgid "Allow inbound UDP traffic from WAN to the local Tailscale listen port so remote peers can establish direct connections without first using DERP."
msgstr ""
msgid "WAN Direct Source Zones"
msgstr ""
msgid "Firewall source zones allowed to reach the local Tailscale listen port when WAN direct is enabled."
msgstr ""
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:217
msgid "Site To Site"
msgstr ""
@@ -366,6 +230,10 @@ msgstr ""
msgid "StdOut Log"
msgstr ""
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:222
msgid "Subnet Routes"
msgstr ""
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/interface.js:100
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:75
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:77
@@ -408,15 +276,6 @@ msgstr ""
msgid "Unable to load log data:"
msgstr ""
msgid "Log is empty."
msgstr ""
msgid "Unknown error"
msgstr ""
msgid "logread command not found"
msgstr ""
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:256
msgid "Use %s to deploy a private server."
msgstr ""
@@ -434,242 +293,3 @@ msgstr ""
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:158
msgid "Workdir"
msgstr ""
msgid "Online"
msgstr ""
msgid "Offline"
msgstr ""
msgid "Not found"
msgstr ""
msgid "Keepalive"
msgstr ""
msgid "Peer Keepalive"
msgstr ""
msgid "Periodically send lightweight Tailscale pings to selected peers."
msgstr ""
msgid "Keepalive Peers"
msgstr ""
msgid "Only peers advertising subnet routes are shown. Selected peers are periodically pinged to keep cross-subnet paths active."
msgstr ""
msgid "No Available Peers"
msgstr ""
msgid "No subnet routes"
msgstr ""
msgid "Subnets"
msgstr ""
msgid "Keepalive Interval"
msgstr ""
msgid "Seconds between keepalive probes."
msgstr ""
msgid "Failure Log Interval"
msgstr ""
msgid "Seconds between repeated failure log messages for the same peer."
msgstr ""
msgid "AdGuard DNS"
msgstr ""
msgid "AdGuard API URL"
msgstr ""
msgid "AdGuard Username"
msgstr ""
msgid "AdGuard Password"
msgstr ""
msgid "Leave blank to keep the existing AdGuard password; enter a new value to replace it."
msgstr ""
msgid "Status"
msgstr ""
msgid "Checking ..."
msgstr ""
msgid "Pass"
msgstr ""
msgid "Fail"
msgstr ""
msgid "AdGuard process"
msgstr ""
msgid "Port 53 is AdGuard"
msgstr ""
msgid "LAN DHCP advertises this router as DNS"
msgstr ""
msgid "AdGuard API"
msgstr ""
msgid "Tailnet DNS health check"
msgstr ""
msgid "Enable AdGuard DNS Auto Switch"
msgstr ""
msgid "Only enable when all status checks pass."
msgstr ""
msgid "Re-enter the AdGuard password after changing the API URL or username."
msgstr ""
msgid "Save error"
msgstr ""
msgid "An error occurred while saving the form:"
msgstr ""
msgid "Failed to stage protected credentials."
msgstr ""
msgid "Health Check Domain is required before enabling AdGuard DNS auto switch."
msgstr ""
msgid "At least one Expected Internal IP is required before enabling AdGuard DNS auto switch."
msgstr ""
msgid "At least one Default Upstream is required before enabling AdGuard DNS auto switch."
msgstr ""
msgid "At least one Tailnet Upstream is required before enabling AdGuard DNS auto switch."
msgstr ""
msgid "AdGuard DNS auto switch cannot be enabled until every environment status check passes."
msgstr ""
msgid "Default Upstreams"
msgstr ""
msgid "Used when Tailnet DNS is unhealthy."
msgstr ""
msgid "Tailnet Upstreams"
msgstr ""
msgid "Added when Tailnet DNS is healthy."
msgstr ""
msgid "Health Check Domain"
msgstr ""
msgid "Resolved through Tailscale DNS 100.100.100.100. The result must match one of the expected internal IPs."
msgstr ""
msgid "Expected Internal IPs"
msgstr ""
msgid "Check Interval"
msgstr ""
msgid "Success Threshold"
msgstr ""
msgid "Failure Threshold"
msgstr ""
msgid "Unknown user"
msgstr ""
msgid "Offline peers cannot be probed"
msgstr ""
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:345
msgid "Enabled; 4 bypass rules are active"
msgstr ""
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:346
msgid "Enabled; waiting for OpenClash nftables chains"
msgstr ""
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:347
msgid "Disabled; Tailscale traffic is handled by OpenClash"
msgstr ""
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:348
msgid "OpenClash is not installed; bypass is not required"
msgstr ""
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:349
msgid "Unsupported; firewall4/nftables is required"
msgstr ""
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:350
msgid "Configuration error"
msgstr ""
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:352
msgid "Unknown status"
msgstr ""
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:363
msgid "Unable to read OpenClash bypass status."
msgstr ""
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:798
msgid "OpenClash"
msgstr ""
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:800
msgid "Protect Tailscale Traffic (Bypass OpenClash)"
msgstr ""
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:801
msgid "Keep Tailscale control connections, direct connections, Tailnet DNS, and subnet traffic outside OpenClash. When disabled, this traffic is handled by OpenClash; node connectivity, direct paths, subnet access, and internal DNS are no longer protected by this feature. Keep this enabled while using OpenClash."
msgstr ""
msgid "Prioritize Tailscale Routes Before mwan3"
msgstr ""
msgid "Give routes already present in Tailscale table 52 priority over mwan3-marked traffic. This fixes LAN clients being sent to a WAN instead of a Tailnet or routed subnet. Public destinations not present in table 52 continue through mwan3. Disabled leaves mwan3 precedence unchanged. Verify from a real LAN client or with an equivalent mwan3 fwmark; the router's unmarked ip route get alone is insufficient. The feature remains blocked while table 52 has a default route, such as when using an exit node."
msgstr ""
msgid "Policy Routing Status"
msgstr ""
msgid "Enabled; mwan3 is not installed"
msgstr ""
msgid "Enabled; no earlier mwan3-marked rule was detected"
msgstr ""
msgid "Enabled; Tailscale routes take precedence over mwan3"
msgstr ""
msgid "Enabled; waiting for the managed policy rule"
msgstr ""
msgid "Disabled; mwan3 routing is unchanged"
msgstr ""
msgid "Blocked; table 52 contains a default route"
msgstr ""
msgid "Blocked; a mwan3 rule has higher priority than 1000"
msgstr ""
msgid "Blocked; priority 1000 is already in use"
msgstr ""
msgid "Blocked; an existing rule is not managed by this app"
msgstr ""
msgid "Unable to read Tailscale policy-routing status."
msgstr ""
+14 -383
View File
@@ -21,7 +21,7 @@ msgstr "启用路由"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:178
msgid "Accept subnet routes that other nodes advertise."
msgstr "接受其他节点广播的网段。"
msgstr "接受其他节点广播的子网路由。"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:235
msgid "Access Control"
@@ -43,14 +43,6 @@ msgstr "是否注销当前登录并且解绑当前设备?"
msgid "Auth Key"
msgstr "认证密钥"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:267
msgid "Configured"
msgstr "已配置"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:268
msgid "Used once for first-time enrollment or explicit re-enrollment, then removed after a successful connection. Leave blank for normal restarts."
msgstr "仅用于首次注册或明确重新注册,连接成功后自动删除;正常重启请留空。"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:248
msgid "Available flags"
msgstr "可用参数"
@@ -68,10 +60,6 @@ msgstr "正在收集数据..."
msgid "Custom Server Settings"
msgstr "自定义服务器"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:529
msgid "Current device"
msgstr "当前设备"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:182
msgid "Device Name"
msgstr "设备名称"
@@ -95,7 +83,7 @@ msgstr "公开网段"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:208
msgid ""
"Expose physical network routes into Tailscale, e.g. <code>10.0.0.0/24</code>."
msgstr "将本地物理网段发布到 Tailscale,例如:<code>10.0.0.0/24</code>。"
msgstr "广播子网路由至 Tailscale,例如:<code>10.0.0.0/24</code>。"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:243
msgid "Extra Settings"
@@ -125,126 +113,6 @@ msgstr "IPv6地址"
msgid "Interface Info"
msgstr "接口信息"
#: luci-app-tailscale/root/usr/share/luci/menu.d/luci-app-tailscale.json:30
msgid "Peers"
msgstr "对端列表"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:216
msgid "Tailscale Peers"
msgstr "Tailscale 对端"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:217
msgid "View all peers and manually probe whether traffic is direct or relayed through DERP."
msgstr "查看所有对端,并手动探测流量是直连还是通过 DERP 中继。"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:170
msgid "Unable to load Tailscale peer status"
msgstr "无法加载 Tailscale 对端状态。"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:151
msgid "No peers match the selected filter."
msgstr "没有对端符合所选筛选条件。"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:151
msgid "No peers found"
msgstr "没有找到对端"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:191
msgid "No probe target available"
msgstr "没有可用探测目标。"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:55
msgid "Probe failed"
msgstr "探测失败"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:74
msgid "Not probed"
msgstr "未探测"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:84
msgid "Probing..."
msgstr "探测中..."
msgid "Continuing probe %d/%d"
msgstr "继续探测 %d/%d"
msgid "%d probes; direct connection not established"
msgstr "%d 次探测后仍未建立直连"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:85
msgid "Probe"
msgstr "探测"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:62
msgid "Direct"
msgstr "直连"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:63
msgid "DERP"
msgstr "DERP 中继"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:64
msgid "Failed"
msgstr "失败"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:64
msgid "Unknown"
msgstr "未知"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:133
msgid "Filter"
msgstr "筛选"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:142
msgid "All"
msgstr "全部"
msgid "Items per page"
msgstr "每页数量"
msgid "All peers"
msgstr "全部对端"
msgid "Showing %d-%d of %d peers"
msgstr "显示第 %d-%d 台,共 %d 台"
msgid "Page %d / %d"
msgstr "第 %d / %d 页"
msgid "Previous"
msgstr "上一页"
msgid "Next"
msgstr "下一页"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:183
msgid "Name"
msgstr "名称"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:184
msgid "Tailnet IP"
msgstr "Tailnet IP"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:186
msgid "Last Seen"
msgstr "最后在线"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:187
msgid "Role"
msgstr "角色"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:95
msgid "Exit node"
msgstr "出口节点"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:98
msgid "Advertising subnets"
msgstr "发布网段"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:188
msgid "Advertised Subnets"
msgstr "已发布网段"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/interface.js:69
msgid "Interface Name"
msgstr "接口名称"
@@ -304,6 +172,10 @@ msgstr "网络接口信息"
msgid "No Available Exit Nodes"
msgstr "没有可用的出口节点"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:228
msgid "No Available Subnet Routes"
msgstr "没有可用的子网路由"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/interface.js:61
msgid "No interface online."
msgstr "无在线接口。"
@@ -340,6 +212,10 @@ msgstr "滚动到底部"
msgid "Select an online machine name to use as an exit node."
msgstr "选择一个可用的节点名称作为出口节点使用。"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:222
msgid "Select subnet routes advertised by other nodes in Tailscale network."
msgstr "选择非本设备广播的子网路由。"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:258
msgid "Server Address"
msgstr "服务器地址"
@@ -348,18 +224,6 @@ msgstr "服务器地址"
msgid "Set the Tailscale port number."
msgstr "设置 Tailscale 端口号。"
msgid "Allow WAN Direct"
msgstr "允许 WAN 直连"
msgid "Allow inbound UDP traffic from WAN to the local Tailscale listen port so remote peers can establish direct connections without first using DERP."
msgstr "允许 WAN 入站 UDP 流量访问本机 Tailscale 监听端口,使远端节点可冷启动直连,不必先经过 DERP。"
msgid "WAN Direct Source Zones"
msgstr "WAN 直连来源区域"
msgid "Firewall source zones allowed to reach the local Tailscale listen port when WAN direct is enabled."
msgstr "允许访问本机 Tailscale 监听端口的防火墙来源区域。多 WAN 环境可填写多个区域。"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:217
msgid "Site To Site"
msgstr "子网互通"
@@ -372,6 +236,10 @@ msgstr "错误日志"
msgid "StdOut Log"
msgstr "运行日志"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:222
msgid "Subnet Routes"
msgstr "子网路由"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/interface.js:100
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:75
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:77
@@ -414,15 +282,6 @@ msgstr "无法获取接口信息:%s。"
msgid "Unable to load log data:"
msgstr "无法读取日志数据:"
msgid "Log is empty."
msgstr "日志为空。"
msgid "Unknown error"
msgstr "未知错误"
msgid "logread command not found"
msgstr "未找到 logread 命令"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:256
msgid "Use %s to deploy a private server."
msgstr "使用 %s 部署私有服务器"
@@ -440,231 +299,3 @@ msgstr "本地广域网访问虚拟局域网"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:158
msgid "Workdir"
msgstr "工作目录"
msgid "Online"
msgstr "在线"
msgid "Offline"
msgstr "离线"
msgid "Not found"
msgstr "未找到"
msgid "Keepalive"
msgstr "保活"
msgid "Peer Keepalive"
msgstr "启用"
msgid "Periodically send lightweight Tailscale pings to selected peers."
msgstr "定时向选中的对端发送轻量 Tailscale ping。"
msgid "Keepalive Peers"
msgstr "保活对端"
msgid "Only peers advertising subnet routes are shown. Selected peers are periodically pinged to keep cross-subnet paths active."
msgstr "仅显示对端也发布了网段的设备;选中的设备会被定时 Tailscale ping,用于保持跨网段路径活跃。"
msgid "No Available Peers"
msgstr "没有可用对端"
msgid "No subnet routes"
msgstr "未发布网段"
msgid "Subnets"
msgstr "子网"
msgid "Keepalive Interval"
msgstr "保活间隔"
msgid "Seconds between keepalive probes."
msgstr "两次保活探测之间的秒数。"
msgid "Failure Log Interval"
msgstr "失败日志间隔"
msgid "Seconds between repeated failure log messages for the same peer."
msgstr "同一对端重复失败日志之间的秒数。"
msgid "AdGuard DNS"
msgstr "AdGuard DNS"
msgid "AdGuard API URL"
msgstr "AdGuard API 地址"
msgid "AdGuard Username"
msgstr "AdGuard 用户名"
msgid "AdGuard Password"
msgstr "AdGuard 密码"
msgid "Leave blank to keep the existing AdGuard password; enter a new value to replace it."
msgstr "留空则保留现有 AdGuard 密码;填写新值则覆盖。"
msgid "Status"
msgstr "状态"
msgid "Checking ..."
msgstr "检查中..."
msgid "Pass"
msgstr "通过"
msgid "Fail"
msgstr "失败"
msgid "AdGuard process"
msgstr "AdGuard 进程"
msgid "Port 53 is AdGuard"
msgstr "53 端口由 AdGuard 接管"
msgid "LAN DHCP advertises this router as DNS"
msgstr "LAN DHCP 下发本路由器作为 DNS"
msgid "AdGuard API"
msgstr "AdGuard API"
msgid "Tailnet DNS health check"
msgstr "Tailnet DNS 健康检查"
msgid "Enable AdGuard DNS Auto Switch"
msgstr "启用 AdGuard DNS 自动切换"
msgid "Only enable when all status checks pass."
msgstr "仅在所有状态检查通过后启用。"
msgid "Re-enter the AdGuard password after changing the API URL or username."
msgstr "修改 API URL 或用户名后,请重新输入 AdGuard 密码。"
msgid "Save error"
msgstr "保存出错"
msgid "An error occurred while saving the form:"
msgstr "保存表单时出错:"
msgid "Failed to stage protected credentials."
msgstr "暂存受保护凭证失败。"
msgid "Health Check Domain is required before enabling AdGuard DNS auto switch."
msgstr "启用 AdGuard DNS 自动切换前必须填写健康检查域名。"
msgid "At least one Expected Internal IP is required before enabling AdGuard DNS auto switch."
msgstr "启用 AdGuard DNS 自动切换前至少需要填写一个预期内网 IP。"
msgid "At least one Default Upstream is required before enabling AdGuard DNS auto switch."
msgstr "启用 AdGuard DNS 自动切换前至少需要填写一个默认上游。"
msgid "At least one Tailnet Upstream is required before enabling AdGuard DNS auto switch."
msgstr "启用 AdGuard DNS 自动切换前至少需要填写一个 Tailnet 上游。"
msgid "AdGuard DNS auto switch cannot be enabled until every environment status check passes."
msgstr "所有环境状态检查通过后才能启用 AdGuard DNS 自动切换。"
msgid "Default Upstreams"
msgstr "默认上游"
msgid "Used when Tailnet DNS is unhealthy."
msgstr "Tailnet DNS 不健康时使用。"
msgid "Tailnet Upstreams"
msgstr "Tailnet 上游"
msgid "Added when Tailnet DNS is healthy."
msgstr "Tailnet DNS 健康时追加使用。"
msgid "Health Check Domain"
msgstr "健康检查域名"
msgid "Resolved through Tailscale DNS 100.100.100.100. The result must match one of the expected internal IPs."
msgstr "通过 Tailscale DNS 100.100.100.100 解析,结果必须匹配任一预期内网 IP。"
msgid "Expected Internal IPs"
msgstr "预期内网 IP"
msgid "Check Interval"
msgstr "检查间隔"
msgid "Success Threshold"
msgstr "成功阈值"
msgid "Failure Threshold"
msgstr "失败阈值"
msgid "Unknown user"
msgstr "未知用户"
msgid "Offline peers cannot be probed"
msgstr "离线设备不可探测"
msgid "Enabled; 4 bypass rules are active"
msgstr "已开启,4 条绕过规则已生效"
msgid "Enabled; waiting for OpenClash nftables chains"
msgstr "已开启,正在等待 OpenClash 创建 nftables 链"
msgid "Disabled; Tailscale traffic is handled by OpenClash"
msgstr "已关闭,Tailscale 流量将由 OpenClash 处理"
msgid "OpenClash is not installed; bypass is not required"
msgstr "未安装 OpenClash,无需绕过"
msgid "Unsupported; firewall4/nftables is required"
msgstr "当前系统不支持,仅支持 firewall4/nftables"
msgid "Configuration error"
msgstr "配置错误"
msgid "Unknown status"
msgstr "未知状态"
msgid "Unable to read OpenClash bypass status."
msgstr "无法读取 OpenClash 绕过状态。"
msgid "OpenClash"
msgstr "OpenClash"
msgid "Protect Tailscale Traffic (Bypass OpenClash)"
msgstr "保护 Tailscale 流量(绕过 OpenClash)"
msgid "Keep Tailscale control connections, direct connections, Tailnet DNS, and subnet traffic outside OpenClash. When disabled, this traffic is handled by OpenClash; node connectivity, direct paths, subnet access, and internal DNS are no longer protected by this feature. Keep this enabled while using OpenClash."
msgstr "开启后,Tailscale 控制连接、直连通信、Tailnet DNS 和跨子网流量不会被 OpenClash 接管。关闭后,这些流量将重新经过 OpenClash,节点在线、点对点直连、跨子网访问和 Tailnet DNS 解析不再受本功能保护;如果 OpenClash 规则接管或重定向这些流量,可能导致节点掉线、直连退化为 DERP、跨子网访问或 Tailnet DNS 中断。使用 OpenClash 时必须保持开启。"
msgid "Prioritize Tailscale Routes Before mwan3"
msgstr "优先让 Tailscale 路由先于 mwan3"
msgid "Give routes already present in Tailscale table 52 priority over mwan3-marked traffic. This fixes LAN clients being sent to a WAN instead of a Tailnet or routed subnet. Public destinations not present in table 52 continue through mwan3. Disabled leaves mwan3 precedence unchanged. Verify from a real LAN client or with an equivalent mwan3 fwmark; the router's unmarked ip route get alone is insufficient. The feature remains blocked while table 52 has a default route, such as when using an exit node."
msgstr "让已存在于 Tailscale 路由表 52 中的路由优先于带 mwan3 标记的流量。这可以修复 LAN 客户端访问 Tailnet 或已路由子网时被错误发往 WAN 的问题。不在路由表 52 中的公网目标仍由 mwan3 处理。关闭后保持 mwan3 原有优先级。请从真实 LAN 客户端验证,或使用等效的 mwan3 fwmark;仅在路由器上执行未标记的 ip route get 不足以验证。若路由表 52 存在默认路由(例如正在使用出口节点),功能会保持阻止状态。"
msgid "Policy Routing Status"
msgstr "策略路由状态"
msgid "Enabled; mwan3 is not installed"
msgstr "已开启,未安装 mwan3"
msgid "Enabled; no earlier mwan3-marked rule was detected"
msgstr "已开启,未检测到更早的 mwan3 标记规则"
msgid "Enabled; Tailscale routes take precedence over mwan3"
msgstr "已开启,Tailscale 路由优先于 mwan3"
msgid "Enabled; waiting for the managed policy rule"
msgstr "已开启,正在等待托管策略路由规则"
msgid "Disabled; mwan3 routing is unchanged"
msgstr "已关闭,mwan3 路由保持不变"
msgid "Blocked; table 52 contains a default route"
msgstr "已阻止,路由表 52 包含默认路由"
msgid "Blocked; a mwan3 rule has higher priority than 1000"
msgstr "已阻止,存在优先级数值小于 1000 的 mwan3 规则"
msgid "Blocked; priority 1000 is already in use"
msgstr "已阻止,优先级 1000 已被占用"
msgid "Blocked; an existing rule is not managed by this app"
msgstr "已阻止,现有规则不由本应用管理"
msgid "Unable to read Tailscale policy-routing status."
msgstr "无法读取 Tailscale 策略路由状态。"
+14 -383
View File
@@ -21,7 +21,7 @@ msgstr "啟用路由"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:178
msgid "Accept subnet routes that other nodes advertise."
msgstr "接受其他節點廣播的網段。"
msgstr "接受其他節點廣播的子網路由。"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:235
msgid "Access Control"
@@ -43,14 +43,6 @@ msgstr "是否註銷當前登錄並且解綁當前設備?"
msgid "Auth Key"
msgstr "認證密鑰"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:267
msgid "Configured"
msgstr "已配置"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:268
msgid "Used once for first-time enrollment or explicit re-enrollment, then removed after a successful connection. Leave blank for normal restarts."
msgstr "僅用於首次註冊或明確重新註冊,連線成功後自動刪除;正常重新啟動請留空。"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:248
msgid "Available flags"
msgstr "可用參數"
@@ -68,10 +60,6 @@ msgstr "正在收集數據..."
msgid "Custom Server Settings"
msgstr "自定義伺服器"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:529
msgid "Current device"
msgstr "目前設備"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:182
msgid "Device Name"
msgstr "設備名稱"
@@ -95,7 +83,7 @@ msgstr "公開網段"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:208
msgid ""
"Expose physical network routes into Tailscale, e.g. <code>10.0.0.0/24</code>."
msgstr "將本地物理網段發布到 Tailscale,例如:<code>10.0.0.0/24</code>。"
msgstr "廣播子網路由至 Tailscale,例如:<code>10.0.0.0/24</code>。"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:243
msgid "Extra Settings"
@@ -125,126 +113,6 @@ msgstr "IPv6地址"
msgid "Interface Info"
msgstr "接口信息"
#: luci-app-tailscale/root/usr/share/luci/menu.d/luci-app-tailscale.json:30
msgid "Peers"
msgstr "對端列表"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:216
msgid "Tailscale Peers"
msgstr "Tailscale 對端"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:217
msgid "View all peers and manually probe whether traffic is direct or relayed through DERP."
msgstr "查看所有對端,並手動探測流量是直連還是透過 DERP 中繼。"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:170
msgid "Unable to load Tailscale peer status"
msgstr "無法載入 Tailscale 對端狀態。"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:151
msgid "No peers match the selected filter."
msgstr "沒有對端符合所選篩選條件。"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:151
msgid "No peers found"
msgstr "沒有找到對端"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:191
msgid "No probe target available"
msgstr "沒有可用探測目標。"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:55
msgid "Probe failed"
msgstr "探測失敗"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:74
msgid "Not probed"
msgstr "未探測"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:84
msgid "Probing..."
msgstr "探測中..."
msgid "Continuing probe %d/%d"
msgstr "繼續探測 %d/%d"
msgid "%d probes; direct connection not established"
msgstr "%d 次探測後仍未建立直連"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:85
msgid "Probe"
msgstr "探測"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:62
msgid "Direct"
msgstr "直連"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:63
msgid "DERP"
msgstr "DERP 中繼"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:64
msgid "Failed"
msgstr "失敗"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:64
msgid "Unknown"
msgstr "未知"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:133
msgid "Filter"
msgstr "篩選"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:142
msgid "All"
msgstr "全部"
msgid "Items per page"
msgstr "每頁數量"
msgid "All peers"
msgstr "全部對端"
msgid "Showing %d-%d of %d peers"
msgstr "顯示第 %d-%d 台,共 %d 台"
msgid "Page %d / %d"
msgstr "第 %d / %d 頁"
msgid "Previous"
msgstr "上一頁"
msgid "Next"
msgstr "下一頁"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:183
msgid "Name"
msgstr "名稱"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:184
msgid "Tailnet IP"
msgstr "Tailnet IP"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:186
msgid "Last Seen"
msgstr "最後在線"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:187
msgid "Role"
msgstr "角色"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:95
msgid "Exit node"
msgstr "出口節點"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:98
msgid "Advertising subnets"
msgstr "發布網段"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:188
msgid "Advertised Subnets"
msgstr "已發布網段"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/interface.js:69
msgid "Interface Name"
msgstr "接口名稱"
@@ -304,6 +172,10 @@ msgstr "網絡接口信息"
msgid "No Available Exit Nodes"
msgstr "没有可用的出口節點"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:228
msgid "No Available Subnet Routes"
msgstr "沒有可用的子網路由"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/interface.js:61
msgid "No interface online."
msgstr "無在線接口。"
@@ -340,6 +212,10 @@ msgstr "捲動到尾部"
msgid "Select an online machine name to use as an exit node."
msgstr "選擇一個可用的節點名稱作為出口節點使用。"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:222
msgid "Select subnet routes advertised by other nodes in Tailscale network."
msgstr "選擇非本設備廣播的子網路由。"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:258
msgid "Server Address"
msgstr "伺服器地址"
@@ -348,18 +224,6 @@ msgstr "伺服器地址"
msgid "Set the Tailscale port number."
msgstr "設置 Tailscale 端口號。"
msgid "Allow WAN Direct"
msgstr "允許 WAN 直連"
msgid "Allow inbound UDP traffic from WAN to the local Tailscale listen port so remote peers can establish direct connections without first using DERP."
msgstr "允許 WAN 入站 UDP 流量訪問本機 Tailscale 監聽端口,使遠端節點可冷啟動直連,不必先經過 DERP。"
msgid "WAN Direct Source Zones"
msgstr "WAN 直連來源區域"
msgid "Firewall source zones allowed to reach the local Tailscale listen port when WAN direct is enabled."
msgstr "允許訪問本機 Tailscale 監聽端口的防火牆來源區域。多 WAN 環境可填寫多個區域。"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:217
msgid "Site To Site"
msgstr "子網互通"
@@ -372,6 +236,10 @@ msgstr "錯誤日誌"
msgid "StdOut Log"
msgstr "運行日誌"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:222
msgid "Subnet Routes"
msgstr "子網路由"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/interface.js:100
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:75
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:77
@@ -414,15 +282,6 @@ msgstr "無法獲取接口信息:%s。"
msgid "Unable to load log data:"
msgstr "無法載入日誌檔:"
msgid "Log is empty."
msgstr "日誌為空。"
msgid "Unknown error"
msgstr "未知錯誤"
msgid "logread command not found"
msgstr "未找到 logread 命令"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:256
msgid "Use %s to deploy a private server."
msgstr "使用 %s 部署私有伺服器"
@@ -440,231 +299,3 @@ msgstr "本地廣域網訪問虛擬局域網"
#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:158
msgid "Workdir"
msgstr "工作目錄"
msgid "Online"
msgstr "在線"
msgid "Offline"
msgstr "離線"
msgid "Not found"
msgstr "未找到"
msgid "Keepalive"
msgstr "保活"
msgid "Peer Keepalive"
msgstr "啟用"
msgid "Periodically send lightweight Tailscale pings to selected peers."
msgstr "定時向選中的對端發送輕量 Tailscale ping。"
msgid "Keepalive Peers"
msgstr "保活對端"
msgid "Only peers advertising subnet routes are shown. Selected peers are periodically pinged to keep cross-subnet paths active."
msgstr "僅顯示對端也發布了網段的裝置;選中的裝置會被定時 Tailscale ping,用於保持跨網段路徑活躍。"
msgid "No Available Peers"
msgstr "沒有可用對端"
msgid "No subnet routes"
msgstr "未發布網段"
msgid "Subnets"
msgstr "子網"
msgid "Keepalive Interval"
msgstr "保活間隔"
msgid "Seconds between keepalive probes."
msgstr "兩次保活探測之間的秒數。"
msgid "Failure Log Interval"
msgstr "失敗日誌間隔"
msgid "Seconds between repeated failure log messages for the same peer."
msgstr "同一對端重複失敗日誌之間的秒數。"
msgid "AdGuard DNS"
msgstr "AdGuard DNS"
msgid "AdGuard API URL"
msgstr "AdGuard API 位址"
msgid "AdGuard Username"
msgstr "AdGuard 使用者名稱"
msgid "AdGuard Password"
msgstr "AdGuard 密碼"
msgid "Leave blank to keep the existing AdGuard password; enter a new value to replace it."
msgstr "留空則保留現有 AdGuard 密碼;填寫新值則覆蓋。"
msgid "Status"
msgstr "狀態"
msgid "Checking ..."
msgstr "檢查中..."
msgid "Pass"
msgstr "通過"
msgid "Fail"
msgstr "失敗"
msgid "AdGuard process"
msgstr "AdGuard 行程"
msgid "Port 53 is AdGuard"
msgstr "53 埠由 AdGuard 接管"
msgid "LAN DHCP advertises this router as DNS"
msgstr "LAN DHCP 下發本路由器作為 DNS"
msgid "AdGuard API"
msgstr "AdGuard API"
msgid "Tailnet DNS health check"
msgstr "Tailnet DNS 健康檢查"
msgid "Enable AdGuard DNS Auto Switch"
msgstr "啟用 AdGuard DNS 自動切換"
msgid "Only enable when all status checks pass."
msgstr "僅在所有狀態檢查通過後啟用。"
msgid "Re-enter the AdGuard password after changing the API URL or username."
msgstr "變更 API URL 或使用者名稱後,請重新輸入 AdGuard 密碼。"
msgid "Save error"
msgstr "儲存時發生錯誤"
msgid "An error occurred while saving the form:"
msgstr "儲存表單時發生錯誤:"
msgid "Failed to stage protected credentials."
msgstr "暫存受保護憑證失敗。"
msgid "Health Check Domain is required before enabling AdGuard DNS auto switch."
msgstr "啟用 AdGuard DNS 自動切換前必須填寫健康檢查網域。"
msgid "At least one Expected Internal IP is required before enabling AdGuard DNS auto switch."
msgstr "啟用 AdGuard DNS 自動切換前至少需要填寫一個預期內網 IP。"
msgid "At least one Default Upstream is required before enabling AdGuard DNS auto switch."
msgstr "啟用 AdGuard DNS 自動切換前至少需要填寫一個預設上游。"
msgid "At least one Tailnet Upstream is required before enabling AdGuard DNS auto switch."
msgstr "啟用 AdGuard DNS 自動切換前至少需要填寫一個 Tailnet 上游。"
msgid "AdGuard DNS auto switch cannot be enabled until every environment status check passes."
msgstr "所有環境狀態檢查通過後才能啟用 AdGuard DNS 自動切換。"
msgid "Default Upstreams"
msgstr "預設上游"
msgid "Used when Tailnet DNS is unhealthy."
msgstr "Tailnet DNS 不健康時使用。"
msgid "Tailnet Upstreams"
msgstr "Tailnet 上游"
msgid "Added when Tailnet DNS is healthy."
msgstr "Tailnet DNS 健康時追加使用。"
msgid "Health Check Domain"
msgstr "健康檢查網域"
msgid "Resolved through Tailscale DNS 100.100.100.100. The result must match one of the expected internal IPs."
msgstr "透過 Tailscale DNS 100.100.100.100 解析,結果必須匹配任一預期內網 IP。"
msgid "Expected Internal IPs"
msgstr "預期內網 IP"
msgid "Check Interval"
msgstr "檢查間隔"
msgid "Success Threshold"
msgstr "成功閾值"
msgid "Failure Threshold"
msgstr "失敗閾值"
msgid "Unknown user"
msgstr "未知使用者"
msgid "Offline peers cannot be probed"
msgstr "離線設備不可探測"
msgid "Enabled; 4 bypass rules are active"
msgstr "已開啟,4 條繞過規則已生效"
msgid "Enabled; waiting for OpenClash nftables chains"
msgstr "已開啟,正在等待 OpenClash 建立 nftables 鏈"
msgid "Disabled; Tailscale traffic is handled by OpenClash"
msgstr "已關閉,Tailscale 流量將由 OpenClash 處理"
msgid "OpenClash is not installed; bypass is not required"
msgstr "未安裝 OpenClash,無需繞過"
msgid "Unsupported; firewall4/nftables is required"
msgstr "目前系統不支援,僅支援 firewall4/nftables"
msgid "Configuration error"
msgstr "設定錯誤"
msgid "Unknown status"
msgstr "未知狀態"
msgid "Unable to read OpenClash bypass status."
msgstr "無法讀取 OpenClash 繞過狀態。"
msgid "OpenClash"
msgstr "OpenClash"
msgid "Protect Tailscale Traffic (Bypass OpenClash)"
msgstr "保護 Tailscale 流量(繞過 OpenClash)"
msgid "Keep Tailscale control connections, direct connections, Tailnet DNS, and subnet traffic outside OpenClash. When disabled, this traffic is handled by OpenClash; node connectivity, direct paths, subnet access, and internal DNS are no longer protected by this feature. Keep this enabled while using OpenClash."
msgstr "開啟後,Tailscale 控制連線、直連通訊、Tailnet DNS 和跨子網流量不會被 OpenClash 接管。關閉後,這些流量將重新經過 OpenClash,節點在線、點對點直連、跨子網存取和 Tailnet DNS 解析不再受本功能保護;如果 OpenClash 規則接管或重新導向這些流量,可能導致節點離線、直連退化為 DERP、跨子網存取或 Tailnet DNS 中斷。使用 OpenClash 時必須保持開啟。"
msgid "Prioritize Tailscale Routes Before mwan3"
msgstr "優先讓 Tailscale 路由先於 mwan3"
msgid "Give routes already present in Tailscale table 52 priority over mwan3-marked traffic. This fixes LAN clients being sent to a WAN instead of a Tailnet or routed subnet. Public destinations not present in table 52 continue through mwan3. Disabled leaves mwan3 precedence unchanged. Verify from a real LAN client or with an equivalent mwan3 fwmark; the router's unmarked ip route get alone is insufficient. The feature remains blocked while table 52 has a default route, such as when using an exit node."
msgstr "讓已存在於 Tailscale 路由表 52 中的路由優先於帶 mwan3 標記的流量。這可修復 LAN 用戶端存取 Tailnet 或已路由子網時被錯誤送往 WAN 的問題。不在路由表 52 中的公網目標仍由 mwan3 處理。關閉後保持 mwan3 原有優先順序。請從真實 LAN 用戶端驗證,或使用等效的 mwan3 fwmark;僅在路由器上執行未標記的 ip route get 不足以驗證。若路由表 52 存在預設路由(例如正在使用出口節點),功能會保持阻止狀態。"
msgid "Policy Routing Status"
msgstr "策略路由狀態"
msgid "Enabled; mwan3 is not installed"
msgstr "已開啟,未安裝 mwan3"
msgid "Enabled; no earlier mwan3-marked rule was detected"
msgstr "已開啟,未偵測到較早的 mwan3 標記規則"
msgid "Enabled; Tailscale routes take precedence over mwan3"
msgstr "已開啟,Tailscale 路由優先於 mwan3"
msgid "Enabled; waiting for the managed policy rule"
msgstr "已開啟,正在等待受管理的策略路由規則"
msgid "Disabled; mwan3 routing is unchanged"
msgstr "已關閉,mwan3 路由保持不變"
msgid "Blocked; table 52 contains a default route"
msgstr "已阻止,路由表 52 包含預設路由"
msgid "Blocked; a mwan3 rule has higher priority than 1000"
msgstr "已阻止,存在優先順序數值小於 1000 的 mwan3 規則"
msgid "Blocked; priority 1000 is already in use"
msgstr "已阻止,優先順序 1000 已被占用"
msgid "Blocked; an existing rule is not managed by this app"
msgstr "已阻止,現有規則不由本應用程式管理"
msgid "Unable to read Tailscale policy-routing status."
msgstr "無法讀取 Tailscale 策略路由狀態。"
+5 -30
View File
@@ -3,10 +3,6 @@ config tailscale 'settings'
option enabled '0'
# Set the port to listen on for incoming Tailscale packets (default 41641)
option port '41641'
# Allow WAN inbound UDP traffic to the local Tailscale listen port for first-packet direct connections
#option allow_wan_direct '0'
# Firewall source zones used when WAN direct is enabled
list wan_direct_zones 'wan'
# Path to config file
option config_path '/etc/tailscale'
# Default to using nftables - change below to 'iptables' if still using iptables
@@ -29,40 +25,19 @@ config tailscale 'settings'
#list advertise_routes ''
# Set to '1' to disable subnet route masquerading
#option disable_snat_subnet_routes '1'
# To connect subnets within your tailnet (e.g. 192.168.1.0/24)
# Documentation https://tailscale.com/kb/1214/site-to-site
#list subnet_routes ''
# Set firewall zone (ts_ac_lan: Tailscale access LAN, ts_ac_wan: Tailscale access WAN:, lan_ac_ts: LAN access Tailscale, wan_ac_ts: WAN access Tailscale)
# Documentation https://openwrt.org/docs/guide-user/services/vpn/tailscale/start
#list access 'ts_ac_lan'
#list access 'ts_ac_wan'
#list access 'lan_ac_ts'
#list access 'wan_ac_ts'
# Enable periodic Tailscale pings to keep selected peer paths warm
#option keepalive_enabled '0'
# Select peer hostnames from tailscale status --json
#list keepalive_peers ''
# Seconds between keepalive probes
option keepalive_interval '20'
# Seconds between repeated failure log messages for the same peer
option keepalive_failure_log_interval '300'
# Enable automatic AdGuard Home upstream switching based on tailnet DNS health
#option adguard_dns_switch_enabled '0'
# Configure at least one default upstream in LuCI before enabling the switch
#list adguard_default_upstreams ''
# Configure at least one tailnet upstream in LuCI before enabling the switch
#list adguard_tailnet_upstreams ''
# Domain and expected IPs for tailnet DNS health checks through 100.100.100.100; fill both before enabling
#option adguard_health_domain ''
# Add at least one expected IP before enabling the AdGuard DNS switch
#list adguard_health_expected_ips ''
# Seconds between AdGuard DNS health checks
option adguard_check_interval '10'
# Consecutive health check results required before switching profiles
option adguard_success_threshold '2'
option adguard_failure_threshold '2'
# AdGuard Home API endpoint and optional credentials
option adguard_api_url 'http://127.0.0.1:3000'
#option adguard_username ''
# Set extra flags for enabling settings upon the initiation of Tailscale
# Documentation https://tailscale.com/kb/1241/tailscale-up
#list flags ''
# Provide the base URL of a custom server instead of https://controlplane.tailscale.com
#option login_server ''
# Set auth Key of custom server
#option authkey ''
@@ -1,2 +0,0 @@
config openclash 'settings'
option enabled '1'
@@ -1,3 +0,0 @@
config settings 'settings'
# Prefer Tailscale table 52 before mwan3 fwmark policy rules.
option enabled '0'
+8
View File
@@ -0,0 +1,8 @@
#!/bin/sh
tailscale_enable="$(uci get tailscale.settings.enabled)"
[ "$ACTION" = ifup -o "$ACTION" = ifupdate ] || exit 0
[ "$ACTION" = ifupdate -a -z "$IFUPDATE_ADDRESSES" -a -z "$IFUPDATE_DATA" ] && exit 0
[ "$tailscale_enable" -eq "1" ] || exit 0
/etc/init.d/tailscale start > /tmp/tailscale.log 2>&1 &
@@ -1,14 +0,0 @@
#!/bin/sh
POLICY_HELPER="${POLICY_HELPER:-/usr/sbin/tailscale_policy_routing}"
LOGGER_CMD="${LOGGER_CMD:-logger}"
case "${ACTION:-}" in
ifup|ifupdate) ;;
*) exit 0 ;;
esac
[ -x "$POLICY_HELPER" ] || exit 0
"$POLICY_HELPER" ensure >/dev/null 2>&1 || \
"$LOGGER_CMD" -p daemon.err -t tailscale_policy_routing 'failed to restore Tailscale table 52 policy priority'
+44 -202
View File
@@ -5,20 +5,12 @@ START=90
USE_PROCD=1
PROG=/usr/sbin/tailscale_helper
PROGK=/usr/sbin/tailscale_keepalive
PROGA=/usr/sbin/tailscale_adguard_dns_switch
PROGS=/usr/sbin/tailscale_secrets
PROGD=/usr/sbin/tailscaled
CONFIG_PATH=/var/lib/tailscale
ADGUARD_DNS_STATE_DIR="${ADGUARD_DNS_STATE_DIR:-/var/run/tailscale_adguard_dns_switch}"
FIREWALL_PENDING_STATE_FILE="${FIREWALL_PENDING_STATE_FILE:-/var/run/tailscale-firewall-pending}"
service_triggers() {
procd_add_reload_trigger "tailscale"
}
reload_in_progress() {
[ "${TAILSCALE_INTERNAL_RELOAD:-0}" = "1" ]
procd_add_interface_trigger "interface.*.up" wan /etc/init.d/tailscale reload
}
section_enabled() {
@@ -26,49 +18,27 @@ section_enabled() {
[ $enabled -gt 0 ]
}
append_wan_direct_zone() {
wan_direct_zones="${wan_direct_zones:+$wan_direct_zones }$1"
}
normalize_tailscale_port() {
case "$1" in
''|*[!0-9]*) printf '%s\n' 41641 ;;
*)
if [ "$1" -ge 1 ] 2>/dev/null && [ "$1" -le 65535 ] 2>/dev/null; then
printf '%s\n' "$1"
else
printf '%s\n' 41641
fi
;;
esac
}
tailscale_helper() {
local cfg="$1"
local state_was_present="${2:-0}"
local accept_routes hostname accept_dns advertise_exit_node exit_node advertise_routes disable_snat_subnet_routes allow_wan_direct wan_direct_zones access flags login_server std_out std_err port
local accept_routes hostname accept_dns advertise_exit_node exit_node advertise_routes disable_snat_subnet_routes subnet_routes access flags login_server authkey std_out std_err
config_get port $cfg 'port'
port="$(normalize_tailscale_port "$port")"
config_get_bool accept_routes $cfg 'accept_routes' 0
config_get_bool accept_routes $cfg 'accept_routes'
config_get hostname $cfg 'hostname'
config_get_bool accept_dns $cfg 'accept_dns' 1
config_get_bool advertise_exit_node $cfg 'advertise_exit_node' 0
config_get_bool accept_dns $cfg 'accept_dns'
config_get_bool advertise_exit_node $cfg 'advertise_exit_node'
config_get exit_node $cfg 'exit_node'
config_get advertise_routes $cfg 'advertise_routes'
config_get_bool disable_snat_subnet_routes $cfg 'disable_snat_subnet_routes' 0
config_get_bool allow_wan_direct $cfg 'allow_wan_direct' 0
wan_direct_zones=""
config_list_foreach "$cfg" 'wan_direct_zones' append_wan_direct_zone
wan_direct_zones="${wan_direct_zones:-wan}"
config_get_bool disable_snat_subnet_routes $cfg 'disable_snat_subnet_routes'
config_get subnet_routes $cfg 'subnet_routes'
config_get access $cfg 'access'
config_get flags $cfg 'flags'
config_get login_server $cfg 'login_server'
config_get authkey $cfg 'authkey'
config_get_bool std_out $cfg 'log_stdout'
config_get_bool std_err $cfg 'log_stderr'
procd_open_instance
procd_set_param command $PROG --run
procd_set_param command $PROG
[ "$accept_routes" = "1" ] && procd_append_param command --accept-routes=true
[ -n "$hostname" ] && procd_append_param command --hostname="$hostname"
@@ -79,158 +49,48 @@ tailscale_helper() {
procd_append_param command --exit-node-allow-lan-access=true
}
[ -n "$advertise_routes" ] && procd_append_param command --advertise-routes="$(echo $advertise_routes | tr ' ' ',')"
if [ "$disable_snat_subnet_routes" = "1" ]; then
procd_append_param command --snat-subnet-routes=false
else
procd_append_param command --snat-subnet-routes=true
fi
[ "$disable_snat_subnet_routes" = "1" ] && procd_append_param command --snat-subnet-routes=false
[ -n "$flags" ] && procd_append_param command $flags
[ -n "$login_server" ] && procd_append_param command --login-server="$login_server"
[ -n "$authkey" ] && procd_append_param command --authkey="$authkey"
procd_set_param env \
ACCEPT_DNS="$accept_dns" \
EXIT_NODE="$exit_node" \
ACCESS="$access" \
DISABLE_SNAT_SUBNET_ROUTES="$disable_snat_subnet_routes" \
ALLOW_WAN_DIRECT="$allow_wan_direct" \
WAN_DIRECT_ZONES="$wan_direct_zones" \
TAILSCALE_PORT="$port" \
TAILSCALE_STATE_WAS_PRESENT="$state_was_present" \
TAILSCALE_HELPER_STATE_DIR="$CONFIG_PATH" \
FIREWALL_PENDING_STATE_FILE="$FIREWALL_PENDING_STATE_FILE"
SUBNET_ROUTES="$subnet_routes" \
ACCESS="$access"
procd_set_param stdout "$std_out"
procd_set_param stderr "$std_err"
procd_close_instance
}
tailscale_keepalive() {
local cfg="$1"
local keepalive_enabled keepalive_peers keepalive_interval keepalive_failure_log_interval std_out std_err
config_get_bool keepalive_enabled $cfg 'keepalive_enabled' 0
[ "$keepalive_enabled" = "1" ] || return 0
config_get keepalive_peers $cfg 'keepalive_peers'
[ -n "$keepalive_peers" ] || {
logger -p daemon.err -t tailscale "keepalive enabled but no peers configured"
return 0
}
config_get keepalive_interval $cfg 'keepalive_interval' 20
config_get keepalive_failure_log_interval $cfg 'keepalive_failure_log_interval' 300
config_get_bool std_out $cfg 'log_stdout'
config_get_bool std_err $cfg 'log_stderr'
procd_open_instance keepalive
procd_set_param command $PROGK --run "$keepalive_interval" "$keepalive_failure_log_interval"
procd_append_param command $keepalive_peers
procd_set_param respawn
procd_set_param stdout "$std_out"
procd_set_param stderr "$std_err"
procd_close_instance
}
tailscale_adguard_dns() {
local cfg="$1"
local adguard_dns_switch_enabled std_out std_err
local adguard_health_domain adguard_health_expected_ips
local adguard_default_upstreams adguard_tailnet_upstreams
config_get_bool adguard_dns_switch_enabled "$cfg" 'adguard_dns_switch_enabled' 0
[ "$adguard_dns_switch_enabled" = "1" ] || return 0
config_get adguard_health_domain "$cfg" 'adguard_health_domain'
config_get adguard_health_expected_ips "$cfg" 'adguard_health_expected_ips'
config_get adguard_default_upstreams "$cfg" 'adguard_default_upstreams'
config_get adguard_tailnet_upstreams "$cfg" 'adguard_tailnet_upstreams'
[ -n "$adguard_health_domain" ] || {
logger -p daemon.err -t tailscale_adguard_dns "adguard_dns_switch_enabled=1 but adguard_health_domain is not configured"
return 0
}
[ -n "$adguard_health_expected_ips" ] || {
logger -p daemon.err -t tailscale_adguard_dns "adguard_dns_switch_enabled=1 but adguard_health_expected_ips is not configured"
return 0
}
[ -n "$adguard_default_upstreams" ] || {
logger -p daemon.err -t tailscale_adguard_dns "adguard_dns_switch_enabled=1 but adguard_default_upstreams is not configured"
return 0
}
[ -n "$adguard_tailnet_upstreams" ] || {
logger -p daemon.err -t tailscale_adguard_dns "adguard_dns_switch_enabled=1 but adguard_tailnet_upstreams is not configured"
return 0
}
config_get_bool std_out "$cfg" 'log_stdout' 1
config_get_bool std_err "$cfg" 'log_stderr' 1
procd_open_instance adguard_dns
procd_set_param command "$PROGA" --run
procd_set_param respawn
procd_set_param stdout "$std_out"
procd_set_param stderr "$std_err"
procd_close_instance
}
tailscale_adguard_dns_apply_down() {
local cfg="$1"
local adguard_dns_switch_enabled adguard_current_profile
config_get_bool adguard_dns_switch_enabled "$cfg" 'adguard_dns_switch_enabled' 0
adguard_current_profile="$(cat "$ADGUARD_DNS_STATE_DIR/current_profile" 2>/dev/null || true)"
[ "$adguard_current_profile" != "down" ] || return 0
[ "$adguard_dns_switch_enabled" = "1" ] || [ "$adguard_current_profile" = "up" ] || return 0
"$PROGA" --apply-profile down >/dev/null 2>&1 || {
logger -p daemon.err -t tailscale_adguard_dns "failed to restore AdGuard DNS down profile"
return 1
}
}
start_instance() {
local cfg="$1"
local port config_path fw_mode std_out std_err state_file state_was_present allow_wan_direct secrets_ref
config_get secrets_ref "$cfg" 'secrets_ref'
if [ -n "$secrets_ref" ] && ! "$PROGS" activate "$secrets_ref"; then
logger -p daemon.err -t tailscale "failed to activate protected credential version"
return 1
fi
local port config_path fw_mode std_out std_err state_file
if ! section_enabled "$cfg"; then
TAILSCALE_HELPER_STATE_DIR="$CONFIG_PATH" \
FIREWALL_PENDING_STATE_FILE="$FIREWALL_PENDING_STATE_FILE" \
"$PROG" --cleanup-managed-firewall || logger -p daemon.err -t tailscale "failed to remove managed firewall state for disabled service"
echo "disabled in /etc/config/tailscale"
return 1
fi
config_get port $cfg 'port'
port="$(normalize_tailscale_port "$port")"
config_get config_path $cfg 'config_path'
config_get fw_mode $cfg 'fw_mode'
config_get_bool std_out $cfg 'log_stdout'
config_get_bool std_err $cfg 'log_stderr'
config_get_bool allow_wan_direct $cfg 'allow_wan_direct' 0
if [ "$allow_wan_direct" != "1" ]; then
TAILSCALE_HELPER_STATE_DIR="$CONFIG_PATH" \
FIREWALL_PENDING_STATE_FILE="$FIREWALL_PENDING_STATE_FILE" \
"$PROG" --cleanup-wan-direct-firewall || return 1
fi
[ -d $config_path ] || mkdir -p $config_path
[ -d $CONFIG_PATH ] || mkdir -p $CONFIG_PATH
state_file="$config_path/tailscaled.state"
state_was_present=0
[ ! -s "$state_file" ] || state_was_present=1
"$PROGD" --cleanup
/usr/sbin/tailscaled --cleanup
procd_open_instance
procd_set_param command $PROGD
procd_append_param command --port "$port"
[ -n "$port" ] && procd_append_param command --port "$port"
[ -n "$state_file" ] && procd_append_param command --state "$state_file"
procd_set_param env TS_DEBUG_FIREWALL_MODE="$fw_mode"
@@ -240,54 +100,48 @@ start_instance() {
procd_set_param stderr "$std_err"
procd_close_instance
tailscale_helper "$cfg" "$state_was_present"
tailscale_keepalive "$cfg"
tailscale_adguard_dns "$cfg"
tailscale_helper "$cfg"
}
start_service() {
"$PROGS" migrate || {
logger -p daemon.err -t tailscale "failed to migrate protected credentials"
return 1
}
config_load 'tailscale'
config_foreach start_instance 'tailscale'
}
stop_instance() {
local cfg="$1"
local helper_cleanup_failed=0 cleanup_action
if reload_in_progress; then
cleanup_action=--cleanup-exit-node-firewall
else
cleanup_action=--cleanup-managed-firewall
fi
TAILSCALE_HELPER_STATE_DIR="$CONFIG_PATH" \
FIREWALL_PENDING_STATE_FILE="$FIREWALL_PENDING_STATE_FILE" \
"$PROG" "$cleanup_action" || helper_cleanup_failed=1
# Remove dnsmasq settings
MagicDNSSuffix=$(tailscale status --json | awk -F'"' '/"MagicDNSSuffix"/ {last=$(NF-1)} END {print last}')
if [ -n "$MagicDNSSuffix" ]; then
target_address="/$MagicDNSSuffix/100.100.100.100"
indexes=$(uci show dhcp | sed -n 's/^dhcp\.@dnsmasq\[\([0-9][0-9]*\)\]=dnsmasq$/\1/p')
for index in $indexes; do
option="dhcp.@dnsmasq[$index].address"
uci -q del_list "$option=$target_address"
done
fi
for suffix in $(uci show dhcp | grep -E "address=.*/${MagicDNSSuffix}/" | sed "s/'//g"); do
uci -q del_list $suffix
done
# Tailscale owns tailscale0, policy rules, and table 52 routes. Do not reload
# network here; netifd must not manage tailscale0.
# Remove network settings
uci -q delete network.tailscale
uci -q delete network.ts_lan
for route in $(uci show network | grep 'network.ts_subnet[0-9]\+=route' | grep -o 'network.ts_subnet[0-9]\+'); do
uci -q delete $route
done
# Remove firewall settings
index=$(uci show firewall | grep "firewall.@forwarding\[[0-9]\+\]\.src='lan'" -B 1 -A 1 | grep "firewall.@forwarding\[[0-9]\+\]\.dest='wan'" | grep -o '[0-9]\+')
uci -q delete firewall.@forwarding[$index].enabled
uci -q delete firewall.tszone
uci -q delete firewall.ts_ac_lan
uci -q delete firewall.ts_ac_wan
uci -q delete firewall.lan_ac_ts
uci -q delete firewall.wan_ac_ts
# Commit configuration changes and reload service
[ -n "$(uci changes dhcp)" ] && uci commit dhcp && /etc/init.d/dnsmasq reload
[ -n "$(uci changes network)" ] && uci commit network && /etc/init.d/network reload
[ -n "$(uci changes firewall)" ] && uci commit firewall && /etc/init.d/firewall reload
"$PROGD" --cleanup
/usr/sbin/tailscaled --cleanup
# Keep helper recovery state available when firewall cleanup needs a retry.
[ "$helper_cleanup_failed" -ne 0 ] || rm -rf "$CONFIG_PATH"
[ "$helper_cleanup_failed" -eq 0 ]
# Remove existing link or folder
rm -rf "$CONFIG_PATH"
}
stop_service() {
@@ -295,19 +149,7 @@ stop_service() {
config_foreach stop_instance 'tailscale'
}
service_stopped() {
config_load 'tailscale'
config_foreach tailscale_adguard_dns_apply_down 'tailscale'
}
reload_service() {
local reload_status=0
TAILSCALE_INTERNAL_RELOAD=1
stop || reload_status=1
TAILSCALE_INTERNAL_RELOAD=0
if ! start; then
reload_status=1
stop || reload_status=1
fi
return "$reload_status"
stop
start
}
@@ -1,25 +0,0 @@
#!/bin/sh /etc/rc.common
START=99
USE_PROCD=1
PROG="${PROG:-/usr/sbin/tailscale_openclash_bypass}"
service_triggers() {
procd_add_reload_trigger "tailscale_openclash"
}
sync_instance() {
"$PROG" sync
}
start_service() {
sync_instance
}
reload_service() {
start_service
}
stop_service() {
"$PROG" cleanup
}
@@ -1,30 +0,0 @@
#!/bin/sh /etc/rc.common
START=91
USE_PROCD=1
PROG="${PROG:-/usr/sbin/tailscale_policy_routing}"
service_triggers() {
procd_add_reload_trigger "tailscale_policy_routing"
}
start_service() {
"$PROG" sync || true
"$PROG" enabled || return 0
procd_open_instance monitor
procd_set_param command "$PROG" monitor
procd_set_param respawn
procd_close_instance
}
reload_service() {
start_service
}
stop_service() {
# Persistent cleanup is reserved for an explicit disable or package removal.
# A normal service stop must not erase the enabled UCI declaration.
return 0
}
@@ -1,30 +1,9 @@
#!/bin/sh
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}"
LUCI_INDEX_CACHE="${LUCI_INDEX_CACHE:-/tmp/luci-indexcache}"
# 初始化密钥存储
if [ -x "$TAILSCALE_SECRETS_BIN" ]; then
"$TAILSCALE_SECRETS_BIN" migrate || exit 1
fi
# 根据配置启用 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"
uci -q batch <<-EOF >/dev/null
delete ucitrack.@tailscale[-1]
commit ucitrack
EOF
rm -f /tmp/luci-indexcache
exit 0
@@ -1 +0,0 @@
/etc/tailscale/luci-secrets.json
@@ -1,230 +0,0 @@
#!/bin/sh
# Copyright (C) 2026 jearton
# SPDX-License-Identifier: GPL-3.0-only
PRECHECK_BIN="${PRECHECK_BIN:-/usr/sbin/tailscale_adguard_dns_switch}"
SECRETS_BIN="${SECRETS_BIN:-/usr/sbin/tailscale_secrets}"
OPENCLASH_BYPASS_BIN="${OPENCLASH_BYPASS_BIN:-/usr/sbin/tailscale_openclash_bypass}"
POLICY_ROUTING_BIN="${POLICY_ROUTING_BIN:-/usr/sbin/tailscale_policy_routing}"
JQ_BIN="${JQ_BIN:-jq}"
print_methods() {
printf '%s\n' '{"secret_status":{},"openclash_bypass_status":{},"policy_routing_status":{},"adguard_preflight":{"candidate":"","api_url":"","username":"","password_set":"","password":"","default_upstreams":"","tailnet_upstreams":"","health_domain":"","expected_ips":""},"set_secrets":{"authkey_set":"","authkey":"","adguard_password_set":"","adguard_password":"","api_url":"","username":"","base_ref":""}}'
}
fail_json() {
message="$1"
"$JQ_BIN" -nc --arg error "$message" '{code: 2, ready: "fail", error: $error}'
exit 1
}
parse_request() {
request="$(cat)"
printf '%s' "$request" | "$JQ_BIN" -e '
type == "object" and
(.candidate | type == "string") and
(.api_url | type == "string") and
(.username | type == "string") and
(.password_set | type == "string") and
(.password | type == "string") and
(.default_upstreams | type == "string") and
(.tailnet_upstreams | type == "string") and
(.health_domain | type == "string") and
(.expected_ips | type == "string")
' >/dev/null 2>&1 || fail_json "invalid request"
candidate="$(printf '%s' "$request" | "$JQ_BIN" -r '.candidate')"
api_url="$(printf '%s' "$request" | "$JQ_BIN" -r '.api_url')"
username="$(printf '%s' "$request" | "$JQ_BIN" -r '.username')"
password_set="$(printf '%s' "$request" | "$JQ_BIN" -r '.password_set')"
password="$(printf '%s' "$request" | "$JQ_BIN" -r '.password')"
default_upstreams="$(printf '%s' "$request" | "$JQ_BIN" -r '.default_upstreams')"
tailnet_upstreams="$(printf '%s' "$request" | "$JQ_BIN" -r '.tailnet_upstreams')"
health_domain="$(printf '%s' "$request" | "$JQ_BIN" -r '.health_domain')"
expected_ips="$(printf '%s' "$request" | "$JQ_BIN" -r '.expected_ips')"
case "$candidate" in
0|1) ;;
*) fail_json "invalid candidate mode" ;;
esac
case "$password_set" in
0|1) ;;
*) fail_json "invalid password mode" ;;
esac
if [ "$candidate" = "1" ]; then
case "$api_url" in
http://?*|https://?*) ;;
*) fail_json "AdGuard API URL must use HTTP or HTTPS" ;;
esac
fi
}
run_preflight() {
if [ "$candidate" = "1" ] && [ "$password_set" = "0" ]; then
"$SECRETS_BIN" matches-adguard "$api_url" "$username" >/dev/null 2>&1 || \
fail_json "saved AdGuard password is not bound to this API URL and username"
fi
if [ "$candidate" = "1" ]; then
if output="$(
TAILSCALE_ADGUARD_PREFLIGHT_CANDIDATE=1 \
TAILSCALE_ADGUARD_PREFLIGHT_API_URL="$api_url" \
TAILSCALE_ADGUARD_PREFLIGHT_USERNAME="$username" \
TAILSCALE_ADGUARD_PREFLIGHT_PASSWORD_SET="$password_set" \
TAILSCALE_ADGUARD_PREFLIGHT_PASSWORD="$password" \
TAILSCALE_ADGUARD_PREFLIGHT_DEFAULT_UPSTREAMS="$default_upstreams" \
TAILSCALE_ADGUARD_PREFLIGHT_TAILNET_UPSTREAMS="$tailnet_upstreams" \
TAILSCALE_ADGUARD_PREFLIGHT_HEALTH_DOMAIN="$health_domain" \
TAILSCALE_ADGUARD_PREFLIGHT_EXPECTED_IPS="$expected_ips" \
"$PRECHECK_BIN" --preflight
)"; then
code=0
else
code=$?
fi
else
if output="$("$PRECHECK_BIN" --preflight)"; then
code=0
else
code=$?
fi
fi
printf '%s\n' "$output" | "$JQ_BIN" -Rn --argjson code "$code" '
reduce inputs as $line ({code: $code};
($line | index("=")) as $separator |
if $separator == null then .
else . + {($line[0:$separator]): $line[$separator + 1:]}
end
)
'
}
run_secret_status() {
"$SECRETS_BIN" migrate >/dev/null 2>&1 || fail_json "failed to migrate protected credentials"
authkey_set=0
adguard_password_set=0
"$SECRETS_BIN" has authkey >/dev/null 2>&1 && authkey_set=1
"$SECRETS_BIN" has adguard_password >/dev/null 2>&1 && adguard_password_set=1
"$JQ_BIN" -nc \
--arg authkey_set "$authkey_set" \
--arg adguard_password_set "$adguard_password_set" \
'{authkey_set:$authkey_set,adguard_password_set:$adguard_password_set}'
}
run_openclash_bypass_status() {
if output="$("$OPENCLASH_BYPASS_BIN" status 2>&1)"; then
:
else
fail_json "failed to read OpenClash bypass status"
fi
printf '%s\n' "$output" | "$JQ_BIN" -se 'length == 1 and (.[0] | type == "object")' >/dev/null 2>&1 || \
fail_json "invalid OpenClash bypass status"
printf '%s\n' "$output"
}
run_policy_routing_status() {
if output="$("$POLICY_ROUTING_BIN" status 2>&1)"; then
:
else
fail_json "failed to read Tailscale policy-routing status"
fi
printf '%s\n' "$output" | "$JQ_BIN" -se 'length == 1 and (.[0] | type == "object")' >/dev/null 2>&1 || \
fail_json "invalid Tailscale policy-routing status"
printf '%s\n' "$output"
}
run_set_secrets() {
request="$(cat)"
printf '%s' "$request" | "$JQ_BIN" -e '
def valid_ref_or_empty:
type == "string" and
all(explode[];
(. >= 48 and . <= 57) or
(. >= 65 and . <= 90) or
(. >= 97 and . <= 122) or
. == 45 or . == 46 or . == 95
);
type == "object" and
(.authkey_set | type == "string" and (. == "0" or . == "1")) and
(.authkey | type == "string") and
(.adguard_password_set | type == "string" and (. == "0" or . == "1")) and
(.adguard_password | type == "string") and
(.api_url | type == "string") and
(.username | type == "string") and
(.base_ref | valid_ref_or_empty) and
(if .authkey_set == "1" then (.authkey | length > 0) else true end) and
(if .adguard_password_set == "1" then
(.adguard_password | length > 0)
else true end)
' >/dev/null 2>&1 || fail_json "invalid credential batch request"
authkey_set="$(printf '%s' "$request" | "$JQ_BIN" -r '.authkey_set')"
adguard_password_set="$(printf '%s' "$request" | "$JQ_BIN" -r '.adguard_password_set')"
base_ref="$(printf '%s' "$request" | "$JQ_BIN" -r '.base_ref')"
case "$base_ref" in
*[!A-Za-z0-9_.-]*) fail_json "invalid credential batch request" ;;
esac
if [ "$adguard_password_set" = "1" ]; then
api_url="$(printf '%s' "$request" | "$JQ_BIN" -r '.api_url')"
case "$api_url" in
http://?*|https://?*) ;;
*) fail_json "invalid credential batch request" ;;
esac
fi
if [ "$authkey_set" = "0" ] && [ "$adguard_password_set" = "0" ]; then
"$JQ_BIN" -nc --arg ref "$base_ref" '{code:0,ref:$ref}'
return 0
fi
if ! ref="$(printf '%s' "$request" | "$JQ_BIN" -c '
{
authkey: (if .authkey_set == "1" then .authkey else null end),
adguard: (if .adguard_password_set == "1" then {
password: .adguard_password,
api_url: .api_url,
username: .username
} else null end)
}
' | "$SECRETS_BIN" stage-batch "$base_ref")"; then
fail_json "failed to stage protected credentials"
fi
case "$ref" in
''|*[!A-Za-z0-9_.-]*) fail_json "invalid protected credential reference" ;;
esac
"$JQ_BIN" -nc --arg ref "$ref" '{code:0,ref:$ref}'
}
case "${1:-}" in
list)
print_methods
;;
call)
case "${2:-}" in
secret_status)
run_secret_status
;;
openclash_bypass_status)
run_openclash_bypass_status
;;
policy_routing_status)
run_policy_routing_status
;;
adguard_preflight)
parse_request
run_preflight
;;
set_secrets)
run_set_secrets
;;
*) exit 1 ;;
esac
;;
*)
exit 1
;;
esac
@@ -1,609 +0,0 @@
#!/bin/sh
# Copyright (C) 2026 jearton
# SPDX-License-Identifier: GPL-3.0-only
UCI_CMD="${UCI_CMD:-uci}"
SECRETS_BIN="${SECRETS_BIN:-/usr/sbin/tailscale_secrets}"
CURL_CMD="${CURL_CMD:-curl}"
FLOCK_CMD="${FLOCK_CMD:-flock}"
JQ_CMD="${JQ_CMD:-jq}"
LOGGER_CMD="${LOGGER_CMD:-logger}"
NSLOOKUP_CMD="${NSLOOKUP_CMD:-nslookup}"
PGREP_CMD="${PGREP_CMD:-pgrep}"
NETSTAT_CMD="${NETSTAT_CMD:-netstat}"
UBUS_CMD="${UBUS_CMD:-ubus}"
SLEEP_CMD="${SLEEP_CMD:-sleep}"
DATE_CMD="${DATE_CMD:-date}"
CURL_CONNECT_TIMEOUT="${CURL_CONNECT_TIMEOUT:-3}"
CURL_MAX_TIME="${CURL_MAX_TIME:-10}"
STATE_DIR="${STATE_DIR:-/var/run/tailscale_adguard_dns_switch}"
TAG="tailscale_adguard_dns"
FAILURE_LOG_INTERVAL=300
umask 077
log_msg() {
priority="$1"
message="$2"
"$LOGGER_CMD" -p "$priority" -t "$TAG" "$message"
}
ensure_state_dir() {
if [ -L "$STATE_DIR" ]; then
echo "refusing symlink state directory: $STATE_DIR" >&2
return 1
fi
if [ -e "$STATE_DIR" ]; then
[ -d "$STATE_DIR" ] || return 1
owner="$(ls -nd "$STATE_DIR" 2>/dev/null | awk '{print $3}')"
[ -n "$owner" ] && [ "$owner" = "$(id -u)" ] || return 1
else
mkdir -m 0700 "$STATE_DIR" || return 1
fi
chmod 0700 "$STATE_DIR"
}
state_temp_file() {
prefix="$1"
ensure_state_dir || return 1
mktemp "$STATE_DIR/$prefix.XXXXXX"
}
write_state_atomic() {
destination="$1"
temp_file="$(state_temp_file state)" || return 1
if ! cat >"$temp_file" || ! mv -f "$temp_file" "$destination"; then
rm -f "$temp_file"
return 1
fi
}
state_key() {
printf '%s' "$1" | sed 's/[^A-Za-z0-9_.-]/_/g'
}
log_rate_limited() {
priority="$1"
key="$(state_key "$2")"
message="$3"
last_file="$STATE_DIR/$key.last_error"
last_seen=
now="$($DATE_CMD +%s 2>/dev/null || true)"
case "$now" in
''|*[!0-9]*) now=0 ;;
esac
[ -r "$last_file" ] && last_seen="$(cat "$last_file" 2>/dev/null || true)"
case "$last_seen" in
''|*[!0-9]*) last_seen= ;;
esac
if [ -z "$last_seen" ] || [ $((now - last_seen)) -ge "$FAILURE_LOG_INTERVAL" ]; then
ensure_state_dir || return 1
log_msg "$priority" "$message"
printf '%s\n' "$now" | write_state_atomic "$last_file"
fi
}
candidate_mode() {
[ "${TAILSCALE_ADGUARD_PREFLIGHT_CANDIDATE:-0}" = "1" ]
}
normalize_api_url() {
url="${1:-http://127.0.0.1:3000}"
[ -n "$url" ] || url='http://127.0.0.1:3000'
while [ "${url%/}" != "$url" ]; do
url="${url%/}"
done
case "$url" in
http://?*|https://?*) ;;
*) return 1 ;;
esac
printf '%s\n' "$url"
}
persisted_uci_get() {
"$UCI_CMD" -q get "tailscale.settings.$1" 2>/dev/null || true
}
protected_adguard_password() {
if candidate_mode; then
url="${TAILSCALE_ADGUARD_PREFLIGHT_API_URL:-}"
user="${TAILSCALE_ADGUARD_PREFLIGHT_USERNAME:-}"
else
url="$(persisted_uci_get adguard_api_url)"
user="$(persisted_uci_get adguard_username)"
fi
url="$(normalize_api_url "$url")" || return 0
"$SECRETS_BIN" adguard-password-for "$url" "$user" 2>/dev/null || true
}
uci_get() {
if candidate_mode; then
case "$1" in
adguard_api_url) printf '%s\n' "${TAILSCALE_ADGUARD_PREFLIGHT_API_URL:-}"; return 0 ;;
adguard_username) printf '%s\n' "${TAILSCALE_ADGUARD_PREFLIGHT_USERNAME:-}"; return 0 ;;
adguard_password)
if [ "${TAILSCALE_ADGUARD_PREFLIGHT_PASSWORD_SET:-0}" = "1" ]; then
printf '%s\n' "${TAILSCALE_ADGUARD_PREFLIGHT_PASSWORD:-}"
return 0
fi
protected_adguard_password
return 0
;;
adguard_health_domain) printf '%s\n' "${TAILSCALE_ADGUARD_PREFLIGHT_HEALTH_DOMAIN:-}"; return 0 ;;
esac
fi
if [ "$1" = "adguard_password" ]; then
protected_adguard_password
return 0
fi
persisted_uci_get "$1"
}
uci_get_raw() {
"$UCI_CMD" -q get "$1" 2>/dev/null || true
}
uci_list() {
option="$1"
if candidate_mode; then
case "$option" in
adguard_default_upstreams) value="${TAILSCALE_ADGUARD_PREFLIGHT_DEFAULT_UPSTREAMS:-}" ;;
adguard_tailnet_upstreams) value="${TAILSCALE_ADGUARD_PREFLIGHT_TAILNET_UPSTREAMS:-}" ;;
adguard_health_expected_ips) value="${TAILSCALE_ADGUARD_PREFLIGHT_EXPECTED_IPS:-}" ;;
*) value=__tailscale_no_candidate_value__ ;;
esac
if [ "$value" != "__tailscale_no_candidate_value__" ]; then
printf '%s\n' "$value" | sed '/^$/d'
return 0
fi
fi
"$UCI_CMD" -q get "tailscale.settings.$option" 2>/dev/null | tr ' ' '\n' | sed '/^$/d'
}
enabled() {
[ "$(uci_get adguard_dns_switch_enabled)" = "1" ]
}
dedupe_lines() {
awk 'NF && !seen[$0]++'
}
default_upstreams() {
uci_list adguard_default_upstreams
}
tailnet_upstreams() {
uci_list adguard_tailnet_upstreams
}
default_conditionals_without_tailnet_dns() {
default_upstreams | awk 'index($0, "100.100.100.100") == 0 && index($0, "[") > 0'
}
default_plain_without_tailnet_dns() {
default_upstreams | awk 'index($0, "100.100.100.100") == 0 && index($0, "[") == 0'
}
profile_lines() {
local profile="$1"
case "$profile" in
up)
{
default_conditionals_without_tailnet_dns
tailnet_upstreams
default_plain_without_tailnet_dns
default_upstreams | grep -F '100.100.100.100' || true
} | dedupe_lines
;;
down)
default_upstreams | grep -Fv '100.100.100.100' | dedupe_lines
;;
*)
echo "unknown profile: $profile" >&2
return 2
;;
esac
}
expected_ips() {
uci_list adguard_health_expected_ips
}
health_domain() {
uci_get adguard_health_domain
}
health_dns() {
printf '%s\n' "100.100.100.100"
}
output_has_ip() {
output="$1"
expected="$2"
printf '%s\n' "$output" | awk -v ip="$expected" '
{
pos = index($0, ip)
if (pos > 0) {
before = pos > 1 ? substr($0, pos - 1, 1) : ""
after = substr($0, pos + length(ip), 1)
if (before !~ /[0-9A-Fa-f:.]/ && after !~ /[0-9A-Fa-f:.]/)
found = 1
}
}
END { exit found ? 0 : 1 }
'
}
check_health() {
domain="$(health_domain)"
dns="$(health_dns)"
[ -n "$(tailnet_upstreams)" ] || return 1
[ -n "$domain" ] || return 1
[ -n "$(expected_ips)" ] || return 1
output="$("$NSLOOKUP_CMD" "$domain" "$dns" 2>&1)" || return 1
for expected in $(expected_ips); do
output_has_ip "$output" "$expected" && return 0
done
return 1
}
api_url() {
url="$(uci_get adguard_api_url)"
normalize_api_url "$url"
}
curl_config_escape() {
printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'
}
run_curl() (
user="$(uci_get adguard_username)"
pass="$(uci_get adguard_password)"
if [ -n "$user" ]; then
auth_file="$(state_temp_file curl-auth)" || exit 1
cleanup_curl_auth() {
rm -f "$auth_file"
}
trap cleanup_curl_auth 0 1 2 15
escaped_user="$(curl_config_escape "$user")" || exit 1
escaped_pass="$(curl_config_escape "$pass")" || exit 1
printf 'user = "%s:%s"\n' "$escaped_user" "$escaped_pass" >"$auth_file" || exit 1
chmod 0600 "$auth_file" || exit 1
"$CURL_CMD" --config "$auth_file" "$@"
else
"$CURL_CMD" "$@"
fi
)
curl_get() {
endpoint="$1"
base="$(api_url)" || return 1
run_curl -fsS --connect-timeout "$CURL_CONNECT_TIMEOUT" --max-time "$CURL_MAX_TIME" "$base$endpoint"
}
curl_post_json() {
endpoint="$1"
body_file="$2"
base="$(api_url)" || return 1
run_curl -fsS --connect-timeout "$CURL_CONNECT_TIMEOUT" --max-time "$CURL_MAX_TIME" -H "Content-Type: application/json" -X POST --data-binary "@$body_file" "$base$endpoint"
}
check_adguard_api() (
api_check_body=
api_check_response=
api_check_upstreams=
cleanup_preflight_files() {
for cleanup_file in "$api_check_body" "$api_check_response" "$api_check_upstreams"; do
[ -z "$cleanup_file" ] || rm -f "$cleanup_file"
done
}
trap cleanup_preflight_files 0 1 2 15
api_check_body="$(state_temp_file preflight-body)" || exit 1
api_check_response="$(state_temp_file preflight-response)" || exit 1
api_check_upstreams="$(state_temp_file preflight-upstreams)" || exit 1
curl_get "/control/status" >/dev/null || return 1
profile_lines down >"$api_check_upstreams" || return 1
if profile_is_empty "$api_check_upstreams"; then
log_rate_limited daemon.err api_default_upstreams_empty "AdGuard API preflight requires at least one valid default upstream"
return 1
fi
"$JQ_CMD" -nc --rawfile upstreams "$api_check_upstreams" '
{upstream_dns: ($upstreams | split("\n") | map(select(length > 0)))}
' >"$api_check_body" || {
log_rate_limited daemon.err api_preflight_payload "failed to build AdGuard API preflight payload"
return 1
}
curl_post_json "/control/test_upstream_dns" "$api_check_body" >"$api_check_response" || return 1
"$JQ_CMD" -e '
type == "object" and length > 0 and all(.[]; . == "OK")
' "$api_check_response" >/dev/null || return 1
)
json_replace_upstreams() {
dns_info="$1"
upstream_file="$2"
"$JQ_CMD" -c --rawfile upstreams "$upstream_file" '
.upstream_dns = ($upstreams | split("\n") | map(select(length > 0)))
' "$dns_info"
}
profile_is_empty() {
[ ! -s "$1" ] || ! awk 'NF { found=1 } END { exit found ? 0 : 1 }' "$1"
}
validate_profile_file() {
local profile="$1"
local upstream_file="$2"
if profile_is_empty "$upstream_file"; then
log_rate_limited daemon.err "profile_empty_$profile" "generated AdGuard DNS $profile profile is empty"
return 1
fi
if [ "$profile" = "up" ]; then
[ -n "$(tailnet_upstreams)" ] || {
log_rate_limited daemon.err profile_tailnet_upstreams_empty "AdGuard DNS up profile requires tailnet upstreams"
return 1
}
[ -n "$(health_domain)" ] && [ -n "$(expected_ips)" ] || {
log_rate_limited daemon.err profile_health_settings_empty "AdGuard DNS up profile requires health settings"
return 1
}
fi
}
apply_profile_locked() (
profile="$1"
dns_info=
body=
upstreams=
down_snapshot=
cleanup_profile_files() {
for cleanup_file in "$dns_info" "$body" "$upstreams" "$down_snapshot"; do
[ -z "$cleanup_file" ] || rm -f "$cleanup_file"
done
}
trap cleanup_profile_files 0 1 2 15
dns_info="$(state_temp_file dns-info)" || exit 1
body="$(state_temp_file dns-config)" || exit 1
upstreams="$(state_temp_file upstreams)" || exit 1
profile_lines "$profile" >"$upstreams" || {
log_rate_limited daemon.err "profile_generate_$profile" "failed to generate AdGuard DNS $profile profile"
return 1
}
if [ "$profile" = "down" ] && profile_is_empty "$upstreams" && \
[ "$(cat "$STATE_DIR/current_profile" 2>/dev/null || true)" = "up" ] && \
[ -s "$STATE_DIR/down_profile" ]; then
cat "$STATE_DIR/down_profile" >"$upstreams" || return 1
fi
validate_profile_file "$profile" "$upstreams" || return 1
if [ "$profile" = "up" ]; then
down_snapshot="$(state_temp_file down-profile)" || return 1
profile_lines down >"$down_snapshot" || return 1
validate_profile_file down "$down_snapshot" || return 1
cat "$down_snapshot" | write_state_atomic "$STATE_DIR/down_profile" || return 1
fi
curl_get "/control/dns_info" >"$dns_info" || {
log_rate_limited daemon.err profile_read "failed to read AdGuard DNS config"
return 1
}
json_replace_upstreams "$dns_info" "$upstreams" >"$body" || {
log_rate_limited daemon.err "profile_build_$profile" "failed to build AdGuard DNS config payload"
return 1
}
curl_post_json "/control/dns_config" "$body" >/dev/null || {
log_rate_limited daemon.err "profile_write_$profile" "failed to write AdGuard DNS $profile profile"
return 1
}
curl_post_json "/control/cache_clear" /dev/null >/dev/null 2>&1 || \
log_rate_limited daemon.warn "profile_cache_clear_$profile" "AdGuard cache clear failed after $profile profile switch"
printf '%s\n' "$profile" | write_state_atomic "$STATE_DIR/current_profile" || return 1
log_msg daemon.info "applied AdGuard DNS $profile profile"
)
apply_profile() {
profile="$1"
case "$profile" in
up|down) ;;
*)
echo "unknown profile: $profile" >&2
return 2
;;
esac
ensure_state_dir || return 1
exec 9>"$STATE_DIR/profile.lock" || return 1
"$FLOCK_CMD" -x 9 || return 1
apply_profile_locked "$profile"
}
lan_ip() {
ip="$("$UBUS_CMD" call network.interface.lan status 2>/dev/null | \
sed -n 's/.*"address":"\([0-9.][0-9.]*\)".*/\1/p' | head -n 1)"
if [ -n "$ip" ]; then
printf '%s\n' "$ip"
return 0
fi
uci_get_raw network.lan.ipaddr
}
dhcp_options() {
options="$(uci_get_raw dhcp.lan.dhcp_option)"
if [ -n "$options" ]; then
printf '%s\n' "$options"
else
"$UCI_CMD" show dhcp.lan.dhcp_option 2>/dev/null | \
sed -n "s/^dhcp\\.lan\\.dhcp_option='\\(.*\\)'$/\\1/p"
fi
}
check_dhcp_option() {
ip="$(lan_ip)"
[ -n "$ip" ] || return 1
for option in $(dhcp_options); do
[ "$option" = "6,$ip" ] && return 0
done
return 1
}
check_port_53_adguard() {
"$NETSTAT_CMD" -lnup 2>/dev/null | \
grep -E '[:.]53[[:space:]]' | \
grep -F 'AdGuardHome' >/dev/null
}
preflight() {
ready=pass
for item in adguard_process port_53_adguard dhcp_advertises_lan_dns adguard_api health_check; do
case "$item" in
adguard_process) "$PGREP_CMD" -f AdGuardHome >/dev/null 2>&1 ;;
port_53_adguard) check_port_53_adguard ;;
dhcp_advertises_lan_dns) check_dhcp_option ;;
adguard_api) check_adguard_api ;;
health_check) check_health ;;
esac
if [ "$?" -eq 0 ]; then
printf '%s=pass\n' "$item"
else
printf '%s=fail\n' "$item"
ready=fail
fi
done
printf 'ready=%s\n' "$ready"
[ "$ready" = "pass" ]
}
startup_preflight() {
failures=
for item in adguard_process port_53_adguard dhcp_advertises_lan_dns adguard_api; do
case "$item" in
adguard_process) "$PGREP_CMD" -f AdGuardHome >/dev/null 2>&1 ;;
port_53_adguard) check_port_53_adguard ;;
dhcp_advertises_lan_dns) check_dhcp_option ;;
adguard_api) check_adguard_api ;;
esac
if [ "$?" -ne 0 ]; then
failures="${failures:+$failures,}$item"
fi
done
[ -z "$failures" ] || {
printf '%s\n' "$failures"
return 1
}
}
valid_positive_number() {
case "$1" in
''|*[!0-9]*) return 1 ;;
0) return 1 ;;
*) return 0 ;;
esac
}
run_loop() {
enabled || {
log_msg daemon.info "AdGuard DNS auto switch disabled"
return 0
}
interval="$(uci_get adguard_check_interval)"
success_threshold="$(uci_get adguard_success_threshold)"
failure_threshold="$(uci_get adguard_failure_threshold)"
valid_positive_number "$interval" || interval=10
valid_positive_number "$success_threshold" || success_threshold=2
valid_positive_number "$failure_threshold" || failure_threshold=2
ensure_state_dir || return 1
success_count=0
failure_count=0
while :; do
if static_failures="$(startup_preflight)"; then
break
fi
log_rate_limited daemon.err "static_preflight_$static_failures" "AdGuard DNS static preflight failed: $static_failures"
"$SLEEP_CMD" "$interval"
done
while :; do
current="$(cat "$STATE_DIR/current_profile" 2>/dev/null || true)"
desired="$current"
if check_health; then
success_count=$((success_count + 1))
failure_count=0
if [ "$success_count" -ge "$success_threshold" ]; then
desired=up
fi
else
failure_count=$((failure_count + 1))
success_count=0
if [ "$failure_count" -ge "$failure_threshold" ]; then
desired=down
fi
fi
if [ -n "$desired" ] && [ "$desired" != "$current" ]; then
apply_profile "$desired" || true
fi
"$SLEEP_CMD" "$interval"
done
}
case "${1:-}" in
--profile)
profile_lines "${2:-}"
;;
--check-health)
check_health
;;
--preflight)
preflight
;;
--apply-profile)
apply_profile "${2:-}"
;;
--run)
run_loop
;;
*)
echo "usage: $0 --profile up|down | --check-health | --preflight | --apply-profile up|down | --run" >&2
exit 2
;;
esac
+66 -510
View File
@@ -1,523 +1,79 @@
#!/bin/sh
# Tailscale update helper script
# Copyright (C) 2025 asvow
# SPDX-License-Identifier: GPL-3.0-only
TAILSCALE_BIN="${TAILSCALE_BIN:-/usr/sbin/tailscale}"
LOGGER_CMD="${LOGGER_CMD:-logger}"
FLOCK_BIN="${FLOCK_BIN:-flock}"
IFCONFIG_BIN="${IFCONFIG_BIN:-ifconfig}"
FIREWALL_INIT="${FIREWALL_INIT:-/etc/init.d/firewall}"
DNSMASQ_INIT="${DNSMASQ_INIT:-/etc/init.d/dnsmasq}"
TAILSCALE_SECRETS_BIN="${TAILSCALE_SECRETS_BIN:-/usr/sbin/tailscale_secrets}"
TAILSCALE_HELPER_STATE_DIR="${TAILSCALE_HELPER_STATE_DIR:-/var/lib/tailscale}"
EXIT_NODE_FIREWALL_STATE_FILE="$TAILSCALE_HELPER_STATE_DIR/exit_node_firewall_state"
FIREWALL_PENDING_STATE_FILE="${FIREWALL_PENDING_STATE_FILE:-/var/run/tailscale-firewall-pending}"
LOCK_FILE="${LOCK_FILE:-/var/lock/tailscale.lock}"
TAILSCALE_PORT="${TAILSCALE_PORT:-41641}"
TAILSCALE_STATE_WAS_PRESENT="${TAILSCALE_STATE_WAS_PRESENT:-0}"
ALLOW_WAN_DIRECT="${ALLOW_WAN_DIRECT:-0}"
WAN_DIRECT_ZONES="${WAN_DIRECT_ZONES:-wan}"
POLICY_ROUTING_HELPER="${POLICY_ROUTING_HELPER:-/usr/sbin/tailscale_policy_routing}"
log_error() {
"$LOGGER_CMD" -p daemon.err -t tailscale_helper "$(date '+%Y/%m/%d %H:%M:%S') $1"
}
# Error handling function
revert_exit() {
log_error "$1"
uci revert dhcp && uci revert network && uci revert firewall
exit 1
}
find_lan_wan_forwarding() {
uci show firewall | awk -F= '
$1 ~ /^firewall\.@forwarding\[[0-9]+\]$/ && $2 == "forwarding" {
section=$1
next
}
$1 ~ /^firewall\.@forwarding\[[0-9]+\]\.src$/ {
value=$2
gsub(/\047/, "", value)
if (section != "" && value == "lan") {
src[section]=1
}
next
}
$1 ~ /^firewall\.@forwarding\[[0-9]+\]\.dest$/ {
value=$2
gsub(/\047/, "", value)
if (section != "" && value == "wan") {
dest[section]=1
}
}
END {
for (section in src) {
if (dest[section]) {
print section
exit
}
}
}
'
}
valid_port() {
case "$1" in
''|*[!0-9]*) return 1 ;;
*) [ "$1" -ge 1 ] && [ "$1" -le 65535 ] ;;
esac
}
normalize_uci_id() {
printf '%s' "$1" | sed 's/[^A-Za-z0-9_]/_/g'
}
firewall_section_has_only_options() {
local section="$1"
local allowed="$2"
local output option
output="$(uci -q show "firewall.$section" 2>/dev/null)" || return 1
for option in $(printf '%s\n' "$output" | sed -n "s/^firewall\\.$section\\.\\([^.=]*\\)=.*/\\1/p"); do
case " $allowed " in
*" $option "*) ;;
*) return 1 ;;
esac
done
}
delete_named_firewall_sections() {
local output section
output="$(uci -q show firewall 2>/dev/null)" || return 1
for section in "$@"; do
if printf '%s\n' "$output" | grep -q "^firewall\\.$section="; then
uci -q delete "firewall.$section" || return 1
fi
done
}
delete_wan_direct_rules() {
local output section
output="$(uci -q show firewall 2>/dev/null)" || return 1
if printf '%s\n' "$output" | grep -q '^firewall\.ts_wan_direct='; then
uci -q delete firewall.ts_wan_direct || return 1
fi
for section in $(printf '%s\n' "$output" | sed -n 's/^firewall\.\(ts_wan_direct_[^.=]*\)=[^=]*$/\1/p'); do
uci -q delete "firewall.$section" || return 1
done
}
wan_direct_rule_matches() {
local section="$1"
local port="$2"
local zone="$3"
[ "$(uci -q get "firewall.$section" 2>/dev/null)" = "rule" ] &&
[ "$(uci -q get "firewall.$section.name" 2>/dev/null)" = "Allow-Tailscale-WAN-$port-$zone" ] &&
[ "$(uci -q get "firewall.$section.src" 2>/dev/null)" = "$zone" ] &&
[ "$(uci -q get "firewall.$section.proto" 2>/dev/null)" = "udp" ] &&
[ "$(uci -q get "firewall.$section.dest_port" 2>/dev/null)" = "$port" ] &&
[ "$(uci -q get "firewall.$section.target" 2>/dev/null)" = "ACCEPT" ] &&
firewall_section_has_only_options "$section" "name src proto dest_port target"
}
wan_direct_rules_match() {
local port="$1"
local output zones zone zone_id section index seen current_count
output="$(uci -q show firewall 2>/dev/null)" || return 1
printf '%s\n' "$output" | grep -q '^firewall\.ts_wan_direct=' && return 1
current_count=0
for section in $(printf '%s\n' "$output" | sed -n 's/^firewall\.\(ts_wan_direct_[^.=]*\)=[^=]*$/\1/p'); do
current_count=$((current_count + 1))
done
if [ "$ALLOW_WAN_DIRECT" != "1" ]; then
[ "$current_count" -eq 0 ]
return $?
fi
zones="${WAN_DIRECT_ZONES:-wan}"
index=0
seen=" "
for zone in $zones; do
[ -n "$zone" ] || continue
case "$seen" in
*" $zone "*) continue ;;
esac
seen="${seen}${zone} "
index=$((index + 1))
zone_id="$(normalize_uci_id "$zone")"
[ -n "$zone_id" ] || zone_id="zone"
section="ts_wan_direct_${index}_${zone_id}"
wan_direct_rule_matches "$section" "$port" "$zone" || return 1
done
[ "$index" -gt 0 ] && [ "$current_count" -eq "$index" ]
}
configure_wan_direct_rule() {
local port zones zone zone_id section index seen
port="$TAILSCALE_PORT"
valid_port "$port" || port=41641
wan_direct_rules_match "$port" && return 0
delete_wan_direct_rules || return 1
[ "$ALLOW_WAN_DIRECT" = "1" ] || return 0
zones="${WAN_DIRECT_ZONES:-wan}"
index=0
seen=" "
for zone in $zones; do
[ -n "$zone" ] || continue
case "$seen" in
*" $zone "*) continue ;;
esac
seen="${seen}${zone} "
index=$((index + 1))
zone_id="$(normalize_uci_id "$zone")"
[ -n "$zone_id" ] || zone_id="zone"
section="ts_wan_direct_${index}_${zone_id}"
uci set "firewall.$section=rule" || return 1
uci set "firewall.$section.name=Allow-Tailscale-WAN-$port-$zone" || return 1
uci set "firewall.$section.src=$zone" || return 1
uci set "firewall.$section.proto=udp" || return 1
uci set "firewall.$section.dest_port=$port" || return 1
uci set "firewall.$section.target=ACCEPT" || return 1
done
[ "$index" -gt 0 ] || return 1
}
access_has() {
case " $ACCESS " in
*" $1 "*) return 0 ;;
*) return 1 ;;
esac
}
tailscale_zone_matches() {
local masq="$1"
[ "$(uci -q get firewall.tszone 2>/dev/null)" = "zone" ] &&
[ "$(uci -q get firewall.tszone.input 2>/dev/null)" = "ACCEPT" ] &&
[ "$(uci -q get firewall.tszone.output 2>/dev/null)" = "ACCEPT" ] &&
[ "$(uci -q get firewall.tszone.forward 2>/dev/null)" = "ACCEPT" ] &&
[ "$(uci -q get firewall.tszone.masq 2>/dev/null)" = "$masq" ] &&
[ "$(uci -q get firewall.tszone.mtu_fix 2>/dev/null)" = "1" ] &&
[ "$(uci -q get firewall.tszone.name 2>/dev/null)" = "tailscale" ] &&
[ "$(uci -q get firewall.tszone.device 2>/dev/null)" = "tailscale0" ] &&
firewall_section_has_only_options tszone "input output forward masq mtu_fix name device"
}
forwarding_rule_matches() {
local section="$1"
local src="$2"
local dest="$3"
[ "$(uci -q get "firewall.$section" 2>/dev/null)" = "forwarding" ] &&
[ "$(uci -q get "firewall.$section.src" 2>/dev/null)" = "$src" ] &&
[ "$(uci -q get "firewall.$section.dest" 2>/dev/null)" = "$dest" ] &&
firewall_section_has_only_options "$section" "src dest"
}
tailscale_access_rules_match() {
local masq="$1"
local output section token src dest zone_required=0
output="$(uci -q show firewall 2>/dev/null)" || return 1
[ -n "$ACCESS" ] && zone_required=1
[ "$DISABLE_SNAT_SUBNET_ROUTES" = "1" ] && zone_required=1
if [ "$zone_required" = 0 ]; then
for section in tszone ts_ac_lan ts_ac_wan lan_ac_ts wan_ac_ts; do
printf '%s\n' "$output" | grep -q "^firewall\\.$section=" && return 1
done
return 0
fi
tailscale_zone_matches "$masq" || return 1
for token in ts_ac_lan ts_ac_wan lan_ac_ts wan_ac_ts; do
case "$token" in
ts_ac_lan) src=tailscale; dest=lan ;;
ts_ac_wan) src=tailscale; dest=wan ;;
lan_ac_ts) src=lan; dest=tailscale ;;
wan_ac_ts) src=wan; dest=tailscale ;;
esac
if access_has "$token"; then
forwarding_rule_matches "$token" "$src" "$dest" || return 1
elif printf '%s\n' "$output" | grep -q "^firewall\\.$token="; then
return 1
fi
done
}
configure_tailscale_access_rules() {
local masq=1 token src dest
[ "$DISABLE_SNAT_SUBNET_ROUTES" = "1" ] && masq=0
tailscale_access_rules_match "$masq" && return 0
delete_named_firewall_sections tszone ts_ac_lan ts_ac_wan lan_ac_ts wan_ac_ts || return 1
[ -n "$ACCESS" ] || [ "$DISABLE_SNAT_SUBNET_ROUTES" = "1" ] || return 0
uci set firewall.tszone='zone' || return 1
uci set firewall.tszone.input='ACCEPT' || return 1
uci set firewall.tszone.output='ACCEPT' || return 1
uci set firewall.tszone.forward='ACCEPT' || return 1
uci set "firewall.tszone.masq=$masq" || return 1
uci set firewall.tszone.mtu_fix='1' || return 1
uci set firewall.tszone.name='tailscale' || return 1
uci add_list firewall.tszone.device='tailscale0' || return 1
for token in ts_ac_lan ts_ac_wan lan_ac_ts wan_ac_ts; do
access_has "$token" || continue
case "$token" in
ts_ac_lan) src=tailscale; dest=lan ;;
ts_ac_wan) src=tailscale; dest=wan ;;
lan_ac_ts) src=lan; dest=tailscale ;;
wan_ac_ts) src=wan; dest=tailscale ;;
esac
uci set "firewall.$token=forwarding" || return 1
uci set "firewall.$token.src=$src" || return 1
uci set "firewall.$token.dest=$dest" || return 1
done
}
apply_firewall_changes() {
local changes pending_dir had_pending=0
changes="$(uci changes firewall 2>/dev/null)" || return 1
[ -e "$FIREWALL_PENDING_STATE_FILE" ] && had_pending=1
if [ -n "$changes" ]; then
pending_dir="${FIREWALL_PENDING_STATE_FILE%/*}"
[ "$pending_dir" = "$FIREWALL_PENDING_STATE_FILE" ] && pending_dir=.
mkdir -p "$pending_dir" || return 1
: >"$FIREWALL_PENDING_STATE_FILE" || return 1
if ! uci commit firewall; then
[ "$had_pending" -eq 1 ] || rm -f "$FIREWALL_PENDING_STATE_FILE"
return 1
fi
fi
[ -e "$FIREWALL_PENDING_STATE_FILE" ] || return 0
if "$FIREWALL_INIT" reload || "$FIREWALL_INIT" restart; then
rm -f "$FIREWALL_PENDING_STATE_FILE" || return 1
return 0
fi
return 1
}
save_exit_node_firewall_state() {
local default_forward lan_wan_section lan_wan_enabled state_tmp
[ -f "$EXIT_NODE_FIREWALL_STATE_FILE" ] && return 0
mkdir -p "$TAILSCALE_HELPER_STATE_DIR" || return 1
state_tmp="${EXIT_NODE_FIREWALL_STATE_FILE}.tmp"
: >"$state_tmp" || return 1
if default_forward="$(uci get firewall.@defaults[0].forward 2>/dev/null)"; then
printf 'default_forward_present=1\n' >>"$state_tmp"
printf 'default_forward_value=%s\n' "$default_forward" >>"$state_tmp"
else
printf 'default_forward_present=0\n' >>"$state_tmp"
fi
lan_wan_section="$(find_lan_wan_forwarding)"
if [ -n "$lan_wan_section" ]; then
printf 'lan_wan_forwarding_present=1\n' >>"$state_tmp"
if lan_wan_enabled="$(uci get "$lan_wan_section.enabled" 2>/dev/null)"; then
printf 'lan_wan_enabled_present=1\n' >>"$state_tmp"
printf 'lan_wan_enabled_value=%s\n' "$lan_wan_enabled" >>"$state_tmp"
else
printf 'lan_wan_enabled_present=0\n' >>"$state_tmp"
fi
else
printf 'lan_wan_forwarding_present=0\n' >>"$state_tmp"
fi
mv "$state_tmp" "$EXIT_NODE_FIREWALL_STATE_FILE"
}
restore_exit_node_firewall_state() {
local default_forward_value default_forward_present lan_wan_enabled_present lan_wan_enabled_value
local lan_wan_section current_value
[ -r "$EXIT_NODE_FIREWALL_STATE_FILE" ] || return 0
default_forward_value=
default_forward_present=0
lan_wan_enabled_present=0
lan_wan_enabled_value=
lan_wan_forwarding_present=0
. "$EXIT_NODE_FIREWALL_STATE_FILE"
if [ "${default_forward_present:-0}" = "1" ]; then
current_value="$(uci get firewall.@defaults[0].forward 2>/dev/null || true)"
if [ "$current_value" != "${default_forward_value:-}" ]; then
uci set "firewall.@defaults[0].forward=${default_forward_value}" || return 1
fi
elif uci get firewall.@defaults[0].forward >/dev/null 2>&1; then
uci -q delete firewall.@defaults[0].forward || return 1
fi
lan_wan_section="$(find_lan_wan_forwarding)"
if [ -n "$lan_wan_section" ] && [ "${lan_wan_forwarding_present:-0}" = "1" ]; then
if [ "${lan_wan_enabled_present:-0}" = "1" ]; then
current_value="$(uci get "$lan_wan_section.enabled" 2>/dev/null || true)"
if [ "$current_value" != "${lan_wan_enabled_value:-}" ]; then
uci set "$lan_wan_section.enabled=${lan_wan_enabled_value}" || return 1
fi
elif uci get "$lan_wan_section.enabled" >/dev/null 2>&1; then
uci -q delete "$lan_wan_section.enabled" || return 1
fi
fi
# Always run the apply path so a previous commit with a pending reload is
# retried even when the restored values already match UCI.
apply_firewall_changes || return 1
rm -f "$EXIT_NODE_FIREWALL_STATE_FILE" || return 1
}
cleanup_managed_firewall() {
restore_exit_node_firewall_state || return 1
ALLOW_WAN_DIRECT=0
configure_wan_direct_rule || return 1
ACCESS=
DISABLE_SNAT_SUBNET_ROUTES=0
configure_tailscale_access_rules || return 1
apply_firewall_changes
}
case "${1:-}" in
--run)
shift
;;
--cleanup-exit-node-firewall)
restore_exit_node_firewall_state || {
log_error "Failed to restore exit-node firewall state."
exit 1
}
exit 0
;;
--cleanup-wan-direct-firewall)
ALLOW_WAN_DIRECT=0
configure_wan_direct_rule || {
log_error "Failed to remove Tailscale WAN direct firewall rules."
exit 1
}
apply_firewall_changes || {
log_error "Failed to apply Tailscale WAN direct firewall cleanup."
exit 1
}
exit 0
;;
--cleanup-managed-firewall)
cleanup_managed_firewall || {
log_error "Failed to remove Tailscale managed firewall state."
exit 1
}
exit 0
;;
*)
log_error "Unsupported tailscale_helper mode: ${1:-<none>}"
exit 2
;;
ARCH=$(uname -m)
case "$ARCH" in
x86_64|amd64) TS_ARCH="amd64" ;;
aarch64|arm64) TS_ARCH="arm64" ;;
armv7l) TS_ARCH="arm" ;;
*) echo '{"error":"unsupported architecture: '$ARCH'"}'; exit 1 ;;
esac
# Use an auth key only for first enrollment or an explicit replacement request.
# Legacy stored keys are ignored when an existing tailscaled identity is present.
authkey=
if [ "$TAILSCALE_STATE_WAS_PRESENT" != "1" ] || \
"$TAILSCALE_SECRETS_BIN" authkey-pending >/dev/null 2>&1; then
authkey="$("$TAILSCALE_SECRETS_BIN" get authkey 2>/dev/null || true)"
fi
if [ -n "$authkey" ]; then
"$TAILSCALE_BIN" up --reset --authkey="$authkey" "$@" || revert_exit "tailscale up failed."
else
"$TAILSCALE_BIN" up --reset "$@" || revert_exit "tailscale up failed."
fi
unset authkey
get_current_version() {
if [ -x /usr/sbin/tailscale ]; then
/usr/sbin/tailscale version 2>/dev/null | head -1 | tr -d '\n'
else
echo "not installed"
fi
}
# Use flock to acquire an exclusive lock
exec 9> "$LOCK_FILE"
"$FLOCK_BIN" -xn 9 || { revert_exit "Failed to acquire lock on $LOCK_FILE"; }
trap 'rm -f "$LOCK_FILE"; exit' INT TERM EXIT
check_update() {
CURRENT=$( get_current_version )
LATEST=$( wget -qO- "https://api.github.com/repos/tailscale/tailscale/releases/latest" 2>/dev/null | \
grep '"tag_name"' | sed 's/.*"v\([^"]*\)".*/\1/' )
if [ -z "$LATEST" ]; then
echo "{\"current\":\"$CURRENT\",\"latest\":\"unknown\",\"error\":\"failed to fetch latest version\"}"
else
echo "{\"current\":\"$CURRENT\",\"latest\":\"$LATEST\"}"
fi
}
# Wait for Tailscale IPv4 address
count=0
while [ -z "$("$IFCONFIG_BIN" | grep 'tailscale' | awk '{print $1}')" ] || [ -z "$("$TAILSCALE_BIN" ip -4)" ]; do
sleep 2
count=$((count + 1))
[ "${count}" -ge 5 ] && revert_exit "Failed to get Tailscale IPv4 address after 5 attempts."
done
do_update() {
LATEST=$( wget -qO- "https://api.github.com/repos/tailscale/tailscale/releases/latest" 2>/dev/null | \
grep '"tag_name"' | sed 's/.*"v\([^"]*\)".*/\1/' )
if [ -z "$LATEST" ]; then
echo '{"error":"failed to fetch latest version"}'
exit 1
fi
# Registration keys are one-time inputs. Purge every stored credential version
# only after the connection has a Tailscale address, so failed enrollment keeps
# the key available for correction and retry.
if "$TAILSCALE_SECRETS_BIN" has authkey >/dev/null 2>&1; then
"$TAILSCALE_SECRETS_BIN" clear-authkey || revert_exit "Failed to clear the used Tailscale auth key."
fi
URL="https://pkgs.tailscale.com/stable/tailscale_${LATEST}_${TS_ARCH}.tgz"
TMP_DIR="/tmp/tailscale_update"
rm -rf "$TMP_DIR"
mkdir -p "$TMP_DIR"
# Configure Tailscale MagicDNS
if [ "$ACCEPT_DNS" = "1" ]; then
MagicDNSSuffix=$("$TAILSCALE_BIN" status --json | awk -F'"' '/"MagicDNSSuffix"/ {last=$(NF-1)} END {print last}')
if [ -n "$MagicDNSSuffix" ]; then
target_address="/$MagicDNSSuffix/100.100.100.100"
index=$(uci show dhcp | sed -n 's/^dhcp\.@dnsmasq\[\([0-9][0-9]*\)\]=dnsmasq$/\1/p')
for i in $index; do
if ! uci -q get "dhcp.@dnsmasq[$i].address" 2>/dev/null | tr ' ' '\n' | grep -qxF "$target_address"; then
uci add_list "dhcp.@dnsmasq[$i].address=$target_address" || revert_exit "Failed to add DNS address."
fi
done
fi
fi
wget -qO "$TMP_DIR/tailscale.tgz" "$URL" 2>/dev/null
if [ ! -s "$TMP_DIR/tailscale.tgz" ]; then
echo "{\"error\":\"download failed: $URL\"}"
rm -rf "$TMP_DIR"
exit 1
fi
# Remove legacy LuCI network wrappers for tailscale0. tailscaled owns the TUN
# device, policy rules, and table 52 routes; netifd must not manage it.
uci -q delete network.tailscale
uci -q delete network.ts_lan
for route in $(uci show network | grep 'network.ts_subnet[0-9]\+=route' | grep -o 'network.ts_subnet[0-9]\+'); do
uci -q delete "$route"
done
tar -xzf "$TMP_DIR/tailscale.tgz" -C "$TMP_DIR" 2>/dev/null
EXTRACT_DIR="$TMP_DIR/tailscale_${LATEST}_${TS_ARCH}"
# Configure exit node firewall rules
if [ -n "$EXIT_NODE" ]; then
save_exit_node_firewall_state || revert_exit "Failed to save exit-node firewall state."
uci set firewall.@defaults[0].forward='REJECT' || revert_exit "Failed to set default forward policy to REJECT."
lan_wan_section="$(find_lan_wan_forwarding)"
[ -n "$lan_wan_section" ] && uci set "$lan_wan_section.enabled=0" || true
else
restore_exit_node_firewall_state || revert_exit "Failed to restore exit-node firewall state."
fi
if [ ! -f "$EXTRACT_DIR/tailscale" ] || [ ! -f "$EXTRACT_DIR/tailscaled" ]; then
echo '{"error":"extracted files not found"}'
rm -rf "$TMP_DIR"
exit 1
fi
configure_wan_direct_rule || revert_exit "Failed to configure Tailscale WAN direct firewall rule."
configure_tailscale_access_rules || revert_exit "Failed to configure Tailscale firewall zone and forwarding rules."
/etc/init.d/tailscale stop 2>/dev/null
sleep 1
# Commit configuration changes and reload service
if [ -n "$(uci changes dhcp)" ]; then
uci commit dhcp || revert_exit "Failed to commit Tailscale DNS configuration."
"$DNSMASQ_INIT" reload || revert_exit "Failed to reload dnsmasq."
fi
if [ -n "$(uci changes network)" ]; then
uci commit network || revert_exit "Failed to commit Tailscale network cleanup."
fi
apply_firewall_changes || revert_exit "Failed to commit or apply Tailscale firewall configuration."
cp "$EXTRACT_DIR/tailscale" /usr/sbin/tailscale
cp "$EXTRACT_DIR/tailscaled" /usr/sbin/tailscaled
chmod +x /usr/sbin/tailscale /usr/sbin/tailscaled
# The policy-routing helper owns only its dedicated network UCI rule and the
# matching runtime rule. Its failure must not disrupt an otherwise healthy node.
if [ -x "$POLICY_ROUTING_HELPER" ] && ! "$POLICY_ROUTING_HELPER" sync; then
log_error "Failed to synchronize Tailscale/mwan3 policy routing precedence."
fi
exit 0
/etc/init.d/tailscale start 2>/dev/null
rm -rf "$TMP_DIR"
NEW_VER=$( get_current_version )
echo "{\"success\":true,\"version\":\"$NEW_VER\"}"
}
case "$1" in
check_update) check_update ;;
do_update) do_update ;;
*) echo '{"error":"usage: tailscale_helper check_update|do_update"}' ;;
esac
@@ -1,286 +0,0 @@
#!/bin/sh
# Copyright (C) 2026 jearton
# SPDX-License-Identifier: GPL-3.0-only
TAILSCALE_BIN="${TAILSCALE_BIN:-/usr/sbin/tailscale}"
LOGGER_CMD="${LOGGER_CMD:-logger}"
JSHN_LIB="${JSHN_LIB:-/usr/share/libubox/jshn.sh}"
log_msg() {
local priority="$1"
local message="$2"
"$LOGGER_CMD" -p "$priority" -t tailscale_keepalive "$message"
}
state_key() {
printf '%s' "$1" | sed 's/[^A-Za-z0-9_.-]/_/g'
}
log_rate_limited_error() {
local key="$1"
local message="$2"
local interval="$3"
local now="$4"
local last_file="$TMP_STATE_DIR/$key.last_error"
local last_seen=0
[ -r "$last_file" ] && last_seen="$(cat "$last_file" 2>/dev/null || echo 0)"
if [ $((now - last_seen)) -ge "$interval" ]; then
log_msg daemon.err "$message"
printf '%s\n' "$now" >"$last_file"
fi
}
read_status() {
if [ -n "${TAILSCALE_STATUS_FILE:-}" ] && [ -r "$TAILSCALE_STATUS_FILE" ]; then
cat "$TAILSCALE_STATUS_FILE"
else
"$TAILSCALE_BIN" status --json
fi
}
normalize_dns() {
printf '%s' "$1" | sed 's/\.$//'
}
short_dns_name() {
normalize_dns "$1" | sed 's/\..*$//'
}
peer_matches() {
local peer="$1"
local host="$2"
local dns="$3"
local matched_ip="$4"
local normalized_dns short_dns
normalized_dns="$(normalize_dns "$dns")"
short_dns="$(short_dns_name "$dns")"
[ "$host" = "$peer" ] || \
[ "$dns" = "$peer" ] || \
[ "$normalized_dns" = "$peer" ] || \
[ "$short_dns" = "$peer" ] || \
[ "$matched_ip" = "$peer" ]
}
emit_resolved_peer() {
local peer="$1"
local host="$2"
local dns="$3"
local ip="$4"
local matched_ip="$5"
[ -n "$host" ] || [ -n "$dns" ] || [ -n "$matched_ip" ] || return 1
peer_matches "$peer" "$host" "$dns" "$matched_ip" || return 1
if [ -n "$matched_ip" ]; then
printf 'OK\t%s\n' "$matched_ip"
elif [ -n "$ip" ]; then
printf 'OK\t%s\n' "$ip"
else
printf 'NO_IP\t\n'
fi
return 0
}
resolve_peer_with_jshn() {
local peer="$1"
local status keys key host dns ips ip matched_ip value
[ -r "$JSHN_LIB" ] || return 1
status="$(read_status)" || return 1
# OpenWrt ships jshn.sh in the jshn package; it parses JSON independently of
# whitespace and field order.
. "$JSHN_LIB" || return 1
json_cleanup
json_load "$status" || {
json_cleanup
return 1
}
json_select Peer || {
json_cleanup
return 1
}
json_get_keys keys
for key in $keys; do
json_select "$key" || continue
host=
dns=
ips=
ip=
matched_ip=
json_get_var host HostName
json_get_var dns DNSName
if json_select TailscaleIPs; then
json_get_values ips
for value in $ips; do
[ -n "$ip" ] || ip="$value"
[ "$value" = "$peer" ] && matched_ip="$value"
done
json_select ..
fi
if emit_resolved_peer "$peer" "$host" "$dns" "$ip" "$matched_ip"; then
json_cleanup
return 0
fi
json_select ..
done
json_cleanup
printf 'NOT_FOUND\t\n'
return 0
}
resolve_peer_with_python() {
local peer="$1"
local status
command -v python3 >/dev/null 2>&1 || return 1
status="$(read_status)" || return 1
STATUS_JSON="$status" python3 - "$peer" <<'PY'
import json
import os
import sys
peer = sys.argv[1]
try:
status = json.loads(os.environ["STATUS_JSON"])
except Exception:
sys.exit(1)
def norm_dns(value):
return value[:-1] if value.endswith(".") else value
def short_dns(value):
return norm_dns(value).split(".", 1)[0]
for item in status.get("Peer", {}).values():
host = item.get("HostName", "")
dns = item.get("DNSName", "")
ips = item.get("TailscaleIPs") or []
matched_ip = next((value for value in ips if value == peer), "")
if peer in (host, dns, norm_dns(dns), short_dns(dns)) or matched_ip:
if matched_ip:
print("OK\t%s" % matched_ip)
elif ips:
print("OK\t%s" % ips[0])
else:
print("NO_IP\t")
sys.exit(0)
print("NOT_FOUND\t")
PY
}
resolve_peer_from_status() {
local peer="$1"
resolve_peer_with_jshn "$peer" && return 0
resolve_peer_with_python "$peer" && return 0
printf 'ERROR\tno JSON parser available\n'
}
resolve_peers() {
local peer result
for peer in "$@"; do
result="$(resolve_peer_from_status "$peer")"
printf '%s\t%s\n' "$peer" "$result"
done
}
run_once() {
local failure_log_interval="$1"
shift
local now peer result status ip peer_key last_success
now="$(date +%s)"
for peer in "$@"; do
peer_key="$(state_key "$peer")"
result="$(resolve_peer_from_status "$peer")"
status="${result%% *}"
ip="${result#* }"
case "$status" in
OK)
if "$TAILSCALE_BIN" ping --timeout=5s --c=1 "$ip" >/dev/null 2>&1; then
last_success="$TMP_STATE_DIR/$peer_key.success"
if [ ! -e "$last_success" ]; then
log_msg daemon.info "peer $peer resolved to $ip and is reachable"
fi
: >"$last_success"
rm -f "$TMP_STATE_DIR/$peer_key.last_error"
else
log_rate_limited_error "$peer_key" "peer $peer resolved to $ip but tailscale ping failed" "$failure_log_interval" "$now"
rm -f "$TMP_STATE_DIR/$peer_key.success"
fi
;;
NO_IP)
log_rate_limited_error "$peer_key" "peer $peer has no Tailscale IP" "$failure_log_interval" "$now"
rm -f "$TMP_STATE_DIR/$peer_key.success"
;;
ERROR)
log_rate_limited_error "$peer_key" "peer $peer resolution failed: $ip" "$failure_log_interval" "$now"
rm -f "$TMP_STATE_DIR/$peer_key.success"
;;
*)
log_rate_limited_error "$peer_key" "peer $peer not found in tailscale status" "$failure_log_interval" "$now"
rm -f "$TMP_STATE_DIR/$peer_key.success"
;;
esac
done
}
run_loop() {
local interval="$1"
local failure_log_interval="$2"
shift 2
[ "$interval" -gt 0 ] 2>/dev/null || interval=20
[ "$failure_log_interval" -gt 0 ] 2>/dev/null || failure_log_interval=300
[ "$#" -gt 0 ] || {
log_msg daemon.err "keepalive enabled but no peers configured"
exit 1
}
TMP_STATE_DIR="/var/run/tailscale_keepalive"
mkdir -p "$TMP_STATE_DIR"
while true; do
run_once "$failure_log_interval" "$@"
sleep "$interval"
done
}
case "$1" in
--resolve-peers)
shift
resolve_peers "$@"
;;
--run)
shift
run_loop "$@"
;;
*)
echo "Usage: $0 --resolve-peers <peer>... | --run <interval> <failure-log-interval> <peer>..." >&2
exit 2
;;
esac
File diff suppressed because it is too large. Load diff
@@ -1,89 +0,0 @@
#!/bin/sh
# Peer probe for LuCI Tailscale diagnostics.
TAILSCALE_BIN="${TAILSCALE_BIN:-/usr/sbin/tailscale}"
JQ_BIN="${JQ_BIN:-/usr/bin/jq}"
emit_json() {
peer="$1"
ok="$2"
path="$3"
latency_ms="$4"
relay="$5"
summary="$6"
raw="$7"
latency_json="null"
[ -n "$latency_ms" ] && latency_json="$latency_ms"
"$JQ_BIN" -nc \
--arg peer "$peer" \
--arg path "$path" \
--arg relay "$relay" \
--arg summary "$summary" \
--arg raw "$raw" \
--argjson ok "$ok" \
--argjson latency_ms "$latency_json" \
'{peer:$peer,ok:$ok,path:$path,latency_ms:$latency_ms,relay:$relay,summary:$summary,raw:$raw}'
}
usage() {
emit_json "" false failed "" "" "usage: tailscale_peer_probe <peer>" ""
exit 2
}
[ "$#" -eq 1 ] || usage
peer="$1"
case "$peer" in
""|*[!A-Za-z0-9_.:-]*)
emit_json "" false failed "" "" "invalid peer argument" ""
exit 2
;;
esac
peer_len=${#peer}
if [ "$peer_len" -lt 1 ] || [ "$peer_len" -gt 253 ]; then
emit_json "" false failed "" "" "invalid peer argument" ""
exit 2
fi
raw="$("$TAILSCALE_BIN" ping --c=1 --timeout=2s "$peer" 2>&1)"
rc="$?"
latency_ms="$(printf '%s\n' "$raw" | sed -nE 's/.* in ([0-9]+(\.[0-9]+)?)ms.*/\1/p' | head -n 1)"
relay="$(printf '%s\n' "$raw" | sed -nE 's/.*DERP\(([^)]*)\).*/\1/p' | head -n 1)"
direct_note="$(printf '%s\n' "$raw" | sed -n '/direct connection not established/{p;q;}')"
if [ -n "$relay" ]; then
if [ -n "$latency_ms" ]; then
summary="DERP $relay $latency_ms ms"
else
summary="DERP $relay"
fi
[ -n "$direct_note" ] && summary="$summary - direct connection not established"
emit_json "$peer" true derp "$latency_ms" "$relay" "$summary" "$raw"
exit 0
fi
if [ "$rc" -ne 0 ]; then
emit_json "$peer" false failed "" "" "tailscale ping failed" "$raw"
exit 0
fi
case "$raw" in
*pong\ from*)
if [ -n "$latency_ms" ]; then
summary="direct $latency_ms ms"
emit_json "$peer" true direct "$latency_ms" "" "$summary" "$raw"
else
summary="unknown path"
emit_json "$peer" true unknown "" "" "$summary" "$raw"
fi
;;
*)
emit_json "$peer" true unknown "" "" "unknown path" "$raw"
;;
esac
@@ -1,429 +0,0 @@
#!/bin/sh
# Copyright (C) 2026 jearton
# SPDX-License-Identifier: GPL-3.0-only
UCI_BIN="${UCI_BIN:-uci}"
IP_BIN="${IP_BIN:-ip}"
JQ_BIN="${JQ_BIN:-jq}"
LOGGER_CMD="${LOGGER_CMD:-logger}"
FLOCK_BIN="${FLOCK_BIN:-flock}"
MWAN3_INIT="${MWAN3_INIT:-/etc/init.d/mwan3}"
LOCK_FILE="${LOCK_FILE:-/var/lock/tailscale-policy-routing.lock}"
SLEEP_BIN="${SLEEP_BIN:-sleep}"
MONITOR_INTERVAL="${MONITOR_INTERVAL:-15}"
SETTINGS_PACKAGE='tailscale_policy_routing'
SETTINGS_SECTION='settings'
NETWORK_SECTION='ts_mwan3_table52'
PRIORITY='1000'
TABLE='52'
RULE_NAME='luci-app-tailscale: Tailscale table 52 before mwan3'
log_error() {
"$LOGGER_CMD" -p daemon.err -t tailscale_policy_routing "$1"
}
setting_enabled() {
case "$("$UCI_BIN" -q get "$SETTINGS_PACKAGE.$SETTINGS_SECTION.enabled" 2>/dev/null || true)" in
1) return 0 ;;
0|'') return 1 ;;
*) return 2 ;;
esac
}
network_section_exists() {
"$UCI_BIN" -q get "network.$NETWORK_SECTION" >/dev/null 2>&1
}
network_section_owned() {
[ "$("$UCI_BIN" -q get "network.$NETWORK_SECTION" 2>/dev/null || true)" = 'rule' ] && \
[ "$("$UCI_BIN" -q get "network.$NETWORK_SECTION.name" 2>/dev/null || true)" = "$RULE_NAME" ] && \
[ "$("$UCI_BIN" -q get "network.$NETWORK_SECTION.family" 2>/dev/null || true)" = 'ipv4' ] && \
[ "$("$UCI_BIN" -q get "network.$NETWORK_SECTION.priority" 2>/dev/null || true)" = "$PRIORITY" ] && \
[ "$("$UCI_BIN" -q get "network.$NETWORK_SECTION.lookup" 2>/dev/null || true)" = "$TABLE" ]
}
rule_dump() {
"$IP_BIN" -4 rule show 2>/dev/null
}
route_dump() {
"$IP_BIN" -4 route show table "$TABLE" 2>/dev/null
}
priority_lines() {
printf '%s\n' "$1" | sed -n "/^$PRIORITY:[[:space:]]/p"
}
priority_line_count() {
priority_lines "$1" | sed '/^$/d' | wc -l | tr -d ' '
}
is_exact_rule_line() {
# ip-rule has no comments or other ownership metadata. Only the exact rule we
# create can be treated as app-owned; constrained table-52 rules are foreign.
printf '%s\n' "$1" | grep -Fqx "$PRIORITY: from all lookup $TABLE"
}
runtime_rule_state() {
rules="$1"
lines="$(priority_lines "$rules")"
count="$(priority_line_count "$rules")"
case "$count" in
0) printf '%s\n' 'absent' ;;
1)
if is_exact_rule_line "$lines"; then
printf '%s\n' 'exact'
else
printf '%s\n' 'conflict'
fi
;;
*) printf '%s\n' 'conflict' ;;
esac
}
table_has_default_route() {
route_dump | grep -Eq '^default[[:space:]]'
}
tailscale_rule_priority() {
rule_dump | awk -v preferred="$PRIORITY" -v table="$TABLE" '
$0 ~ ("(lookup|table)[[:space:]]+" table "([[:space:]]|$)") {
priority = $1
sub(/:$/, "", priority)
if (priority != preferred && (best == "" || priority + 0 < best + 0))
best = priority
}
END { print best }
'
}
mwan3_present() {
[ -x "$MWAN3_INIT" ] && \
[ "$("$UCI_BIN" -q get mwan3.globals 2>/dev/null || true)" = 'globals' ]
}
mwan3_mark_mask() {
mask="$("$UCI_BIN" -q get mwan3.globals.mmx_mask 2>/dev/null || true)"
case "$mask" in
0[xX][0-9A-Fa-f]*) printf '%s\n' "$mask" | tr '[:upper:]' '[:lower:]' ;;
*) printf '%s\n' '0x3f00' ;;
esac
}
mwan3_earlier_mark_rule() {
rules="$1"
threshold="$2"
mask="$(mwan3_mark_mask)"
mwan3_present || return 1
printf '%s\n' "$rules" | awk -v threshold="$threshold" -v mask="$mask" '
/fwmark/ && index(tolower($0), "/" mask) && /(lookup|table)/ {
priority = $1
sub(/:$/, "", priority)
if (priority + 0 < threshold + 0) {
found = 1
exit
}
}
END { exit !found }
'
}
write_owned_network_rule() {
"$UCI_BIN" set "network.$NETWORK_SECTION=rule" || return 1
"$UCI_BIN" set "network.$NETWORK_SECTION.name=$RULE_NAME" || return 1
"$UCI_BIN" set "network.$NETWORK_SECTION.family=ipv4" || return 1
"$UCI_BIN" set "network.$NETWORK_SECTION.priority=$PRIORITY" || return 1
"$UCI_BIN" set "network.$NETWORK_SECTION.lookup=$TABLE" || return 1
"$UCI_BIN" commit network
}
delete_owned_network_rule() {
network_section_exists || return 3
network_section_owned || return 2
"$UCI_BIN" delete "network.$NETWORK_SECTION" || return 1
"$UCI_BIN" commit network
}
add_runtime_rule() {
rules="$(rule_dump)" || return 1
case "$(runtime_rule_state "$rules")" in
absent) "$IP_BIN" -4 rule add priority "$PRIORITY" lookup "$TABLE" ;;
exact) return 0 ;;
conflict) return 2 ;;
esac
}
delete_runtime_rule() {
rules="$(rule_dump)" || return 1
case "$(runtime_rule_state "$rules")" in
absent) return 0 ;;
exact) "$IP_BIN" -4 rule del priority "$PRIORITY" lookup "$TABLE" ;;
conflict) return 2 ;;
esac
}
cleanup_owned_state() {
strict_cleanup="${1:-0}"
rules="$(rule_dump)" || return 1
if ! network_section_exists; then
# A normal disable does not adopt or remove manually-created exact rules.
# Package removal is stricter: it must not leave an indistinguishable
# priority-1000 rule behind after the helper is gone.
if [ "$strict_cleanup" = 1 ] && [ "$(runtime_rule_state "$rules")" = exact ]; then
log_error 'refusing package cleanup because an unowned priority 1000 table 52 rule remains'
return 2
fi
return 0
fi
if ! network_section_owned; then
log_error 'refusing to delete a non-owned network rule section'
return 2
fi
delete_runtime_rule
delete_status=$?
case "$delete_status" in
0) ;;
2)
log_error 'refusing to delete conflicting priority 1000 policy rules'
return 2
;;
*) return "$delete_status" ;;
esac
# Keep the ownership declaration until the runtime rule is gone. If deletion
# fails, a later sync can retry safely instead of treating the rule as foreign.
delete_owned_network_rule
}
sync_enabled() {
rules="$(rule_dump)" || return 1
if table_has_default_route; then
cleanup_owned_state
cleanup_status=$?
case "$cleanup_status" in
0) return 0 ;;
2)
log_error 'table 52 has a default route and an unowned rule prevents safe cleanup'
return 1
;;
*) return "$cleanup_status" ;;
esac
fi
if mwan3_earlier_mark_rule "$rules" "$PRIORITY"; then
if network_section_owned; then
delete_runtime_rule
cleanup_status=$?
case "$cleanup_status" in
0) ;;
2)
log_error 'refusing to remove the managed rule because priority 1000 now conflicts'
return 1
;;
*) return "$cleanup_status" ;;
esac
fi
log_error 'mwan3 has a fwmark policy rule before priority 1000; table 52 precedence is blocked'
return 0
fi
if network_section_exists; then
if ! network_section_owned; then
log_error 'network.ts_mwan3_table52 exists but is not owned by luci-app-tailscale'
return 1
fi
else
case "$(runtime_rule_state "$rules")" in
absent) write_owned_network_rule || return 1 ;;
exact)
log_error 'priority 1000 table 52 rule already exists without app ownership'
return 1
;;
conflict)
log_error 'priority 1000 is occupied by another policy rule'
return 1
;;
esac
fi
add_runtime_rule
add_status=$?
case "$add_status" in
0) return 0 ;;
2)
# Keep the declaration so a later sync can safely recover if the foreign
# rule disappears. It also records that the app must not adopt that rule.
log_error 'priority 1000 is occupied by another policy rule'
return 1
;;
*) return "$add_status" ;;
esac
}
sync() {
setting_enabled
enabled_status=$?
case "$enabled_status" in
0) sync_enabled ;;
1) cleanup_owned_state ;;
2)
log_error 'tailscale_policy_routing.settings.enabled is invalid'
return 1
;;
esac
}
ensure() {
setting_enabled
enabled_status=$?
case "$enabled_status" in
0) ;;
1) return 0 ;;
2)
log_error 'tailscale_policy_routing.settings.enabled is invalid'
return 1
;;
esac
rules="$(rule_dump)" || return 1
if table_has_default_route; then
# Keep the persistent declaration for recovery after the exit node is
# removed, but never leave our high-priority runtime rule ahead of a
# default route in table 52.
network_section_owned || return 0
delete_runtime_rule
return $?
fi
if mwan3_earlier_mark_rule "$rules" "$PRIORITY"; then
network_section_owned || return 0
delete_runtime_rule
cleanup_status=$?
case "$cleanup_status" in
0)
log_error 'mwan3 has a fwmark policy rule before priority 1000; table 52 precedence is blocked'
return 0
;;
2)
log_error 'refusing to remove the managed rule because priority 1000 now conflicts'
return 1
;;
*) return "$cleanup_status" ;;
esac
fi
network_section_owned || return 0
add_runtime_rule
}
bool_json() {
if "$1"; then
printf '%s\n' true
else
printf '%s\n' false
fi
}
print_status() {
rules="$(rule_dump)" || exit 1
enabled=false
settings_invalid=false
setting_enabled
enabled_status=$?
case "$enabled_status" in
0) enabled=true ;;
2) settings_invalid=true ;;
esac
runtime_state="$(runtime_rule_state "$rules")"
owned=false
network_section_owned && owned=true
section_exists=false
network_section_exists && section_exists=true
default_route=false
table_has_default_route && default_route=true
tailscale_priority="$(tailscale_rule_priority)"
mwan3=false
mwan3_present && mwan3=true
early_mark=false
if mwan3_earlier_mark_rule "$rules" "$PRIORITY"; then
early_mark=true
fi
if [ "$settings_invalid" = true ]; then
state=error
message='The policy-routing enable setting is invalid.'
elif [ "$enabled" = false ]; then
state=disabled
message='Policy routing precedence is disabled.'
elif [ "$default_route" = true ]; then
state=blocked_default_route
message='Table 52 contains a default route, so precedence is blocked to preserve mwan3 routing.'
elif [ "$early_mark" = true ]; then
state=blocked_mwan3_priority
message='A mwan3 fwmark policy rule precedes priority 1000, so table 52 precedence is blocked.'
elif [ "$section_exists" = true ] && [ "$owned" = false ]; then
state=blocked_ownership
message='The named network rule is not owned by luci-app-tailscale.'
elif [ "$runtime_state" = conflict ]; then
state=blocked_priority_conflict
message='Priority 1000 is occupied by another policy rule.'
elif [ "$runtime_state" = exact ] && [ "$owned" = false ]; then
state=blocked_ownership
message='Priority 1000 lookup 52 exists but is not owned by luci-app-tailscale.'
elif [ "$runtime_state" = exact ] && [ "$owned" = true ]; then
state=active
message='Tailscale table 52 is evaluated before mwan3 policy rules.'
else
state=waiting
message='Waiting to install the managed table 52 policy rule.'
fi
"$JQ_BIN" -nc \
--arg state "$state" \
--arg message "$message" \
--arg tailscale_priority "$tailscale_priority" \
--argjson enabled "$enabled" \
--argjson mwan3_present "$mwan3" \
--argjson mwan3_earlier_mark_rule "$early_mark" \
--argjson managed_network_rule "$owned" \
--argjson runtime_rule_present "$( [ "$runtime_state" = exact ] && printf true || printf false )" \
--argjson default_route "$default_route" \
'{state:$state, message:$message, enabled:$enabled, mwan3_present:$mwan3_present, mwan3_earlier_mark_rule:$mwan3_earlier_mark_rule, managed_network_rule:$managed_network_rule, runtime_rule_present:$runtime_rule_present, default_route:$default_route, tailscale_rule_priority:($tailscale_priority | if . == "" then null else tonumber end)}'
}
monitor() {
while :; do
if setting_enabled; then
# The normal sync and hotplug paths report actionable errors. The monitor
# retries quietly so a persistent external conflict cannot flood syslog.
saved_logger="$LOGGER_CMD"
LOGGER_CMD=:
run_locked ensure || :
LOGGER_CMD="$saved_logger"
fi
"$SLEEP_BIN" "$MONITOR_INTERVAL" || return 0
done
}
run_locked() {
lock_dir="${LOCK_FILE%/*}"
[ "$lock_dir" = "$LOCK_FILE" ] && lock_dir=.
mkdir -p "$lock_dir" || return 1
exec 9>"$LOCK_FILE"
"$FLOCK_BIN" -xn 9 || return 1
"$@"
}
case "${1:-}" in
sync) run_locked sync ;;
ensure) run_locked ensure ;;
cleanup) run_locked cleanup_owned_state 1 ;;
enabled) setting_enabled ;;
monitor) monitor ;;
status) print_status ;;
*)
echo "usage: $0 {sync|ensure|cleanup|enabled|monitor|status}" >&2
exit 2
;;
esac
@@ -1,555 +0,0 @@
#!/bin/sh
set -eu
SECRETS_FILE="${TAILSCALE_SECRETS_FILE:-/etc/tailscale/luci-secrets.json}"
JQ_CMD="${JQ_CMD:-jq}"
UCI_CMD="${UCI_CMD:-uci}"
FLOCK_BIN="${FLOCK_BIN:-flock}"
umask 077
fail() {
printf '%s\n' "$*" >&2
exit 1
}
normalize_api_url() {
url="${1:-http://127.0.0.1:3000}"
[ -n "$url" ] || url='http://127.0.0.1:3000'
while [ "${url%/}" != "$url" ]; do
url="${url%/}"
done
case "$url" in
http://?*|https://?*) ;;
*) return 1 ;;
esac
printf '%s\n' "$url"
}
valid_key() {
case "$1" in
authkey|adguard_password|adguard_api_url|adguard_username) return 0 ;;
*) return 1 ;;
esac
}
valid_ref() {
case "${1:-}" in
''|*[!A-Za-z0-9_.-]*) return 1 ;;
*) return 0 ;;
esac
}
secrets_dir() {
dir="${SECRETS_FILE%/*}"
[ "$dir" != "$SECRETS_FILE" ] || dir='.'
printf '%s\n' "$dir"
}
ensure_secrets_dir() {
dir="$(secrets_dir)"
[ ! -L "$dir" ] || fail "refusing symlinked secrets directory"
[ -d "$dir" ] || mkdir -p "$dir"
chmod 0700 "$dir"
}
lock_secrets() {
ensure_secrets_dir
lock_file="$(secrets_dir)/.luci-secrets.lock"
[ ! -L "$lock_file" ] || fail "refusing symlinked secrets lock"
exec 9>"$lock_file"
chmod 0600 "$lock_file"
"$FLOCK_BIN" -x 9 || fail "failed to lock protected credentials"
}
replace_store() (
source_file="$1"
dir="$(secrets_dir)"
target_tmp="$(mktemp "$dir/.luci-secrets-store.XXXXXX")"
cleanup_replace() {
rm -f "$target_tmp"
}
trap cleanup_replace 0 1 2 15
cat "$source_file" >"$target_tmp"
chmod 0600 "$target_tmp"
mv -f "$target_tmp" "$SECRETS_FILE"
target_tmp=
)
store_is_valid() {
"$JQ_CMD" -e '
def valid_ref:
type == "string" and
length > 0 and
all(explode[];
(. >= 48 and . <= 57) or
(. >= 65 and . <= 90) or
(. >= 97 and . <= 122) or
. == 45 or . == 46 or . == 95
);
.schema == 2 and
(.active_ref | valid_ref) and
(.versions | type == "object") and
(.versions[.active_ref] | type == "object") and
all(.versions[]; type == "object")
' "$SECRETS_FILE" >/dev/null 2>&1
}
ensure_store() (
ensure_secrets_dir
[ ! -L "$SECRETS_FILE" ] || fail "refusing symlinked secrets file"
dir="$(secrets_dir)"
if [ ! -e "$SECRETS_FILE" ]; then
initial_tmp="$(mktemp "$dir/.luci-secrets-initial.XXXXXX")"
cleanup_initial() {
rm -f "$initial_tmp"
}
trap cleanup_initial 0 1 2 15
"$JQ_CMD" -nc '{schema:2,active_ref:"base",versions:{base:{}}}' >"$initial_tmp"
replace_store "$initial_tmp"
rm -f "$initial_tmp"
trap - 0 1 2 15
return 0
fi
if store_is_valid; then
chmod 0600 "$SECRETS_FILE"
return 0
fi
"$JQ_CMD" -e '
type == "object" and
(has("schema") | not) and
(([keys[]] - ["authkey", "adguard_password", "adguard_api_url", "adguard_username"]) | length == 0) and
all(.[]; type == "string")
' "$SECRETS_FILE" >/dev/null 2>&1 || fail "invalid protected credential store"
legacy_tmp="$(mktemp "$dir/.luci-secrets-legacy.XXXXXX")"
cleanup_legacy_store() {
rm -f "$legacy_tmp"
}
trap cleanup_legacy_store 0 1 2 15
"$JQ_CMD" -c '{schema:2,active_ref:"legacy",versions:{legacy:.}}' "$SECRETS_FILE" >"$legacy_tmp"
replace_store "$legacy_tmp"
rm -f "$legacy_tmp"
trap - 0 1 2 15
)
active_ref() {
ensure_store
"$JQ_CMD" -er '.active_ref' "$SECRETS_FILE"
}
version_exists() {
ref="$1"
valid_ref "$ref" || return 1
ensure_store
"$JQ_CMD" -e --arg ref "$ref" '.versions[$ref] | type == "object"' "$SECRETS_FILE" >/dev/null 2>&1
}
new_ref() {
dir="$(secrets_dir)"
while :; do
ref_marker="$(mktemp "$dir/.luci-secret-ref.XXXXXX")"
ref_suffix="${ref_marker##*.}"
rm -f "$ref_marker"
ref="v$(date +%s 2>/dev/null || printf 0)-$$-$ref_suffix"
if ! version_exists "$ref"; then
printf '%s\n' "$ref"
return 0
fi
done
}
stage_update() (
update_file="$1"
base_ref="${2:-}"
ensure_store
[ -n "$base_ref" ] || base_ref="$(active_ref)"
version_exists "$base_ref" || fail "unknown protected credential base reference"
ref="$(new_ref)"
dir="$(secrets_dir)"
staged_tmp="$(mktemp "$dir/.luci-secrets-stage.XXXXXX")"
cleanup_stage() {
rm -f "$staged_tmp"
}
trap cleanup_stage 0 1 2 15
"$JQ_CMD" -ce \
--arg base "$base_ref" \
--arg ref "$ref" \
--slurpfile update "$update_file" \
'.versions[$ref] = (.versions[$base] * $update[0])' \
"$SECRETS_FILE" >"$staged_tmp"
replace_store "$staged_tmp"
rm -f "$staged_tmp"
trap - 0 1 2 15
printf '%s\n' "$ref"
)
activate_ref() (
ref="$1"
version_exists "$ref" || fail "unknown protected credential reference"
dir="$(secrets_dir)"
activate_tmp="$(mktemp "$dir/.luci-secrets-activate.XXXXXX")"
cleanup_activate() {
rm -f "$activate_tmp"
}
trap cleanup_activate 0 1 2 15
"$JQ_CMD" -ce --arg ref "$ref" '.active_ref = $ref' "$SECRETS_FILE" >"$activate_tmp"
replace_store "$activate_tmp"
rm -f "$activate_tmp"
trap - 0 1 2 15
)
activate_and_persist_ref() {
ref="$1"
old_ref="$(active_ref)"
activate_ref "$ref"
if ! "$UCI_CMD" -q set "tailscale.settings.secrets_ref=$ref" || ! "$UCI_CMD" commit tailscale; then
activate_ref "$old_ref" || true
fail "failed to persist protected credential reference"
fi
}
get_value() {
key="$1"
valid_key "$key" || fail "invalid secret key"
ensure_store
"$JQ_CMD" -er --arg key "$key" '.versions[.active_ref][$key] | select(type == "string")' "$SECRETS_FILE"
}
get_required_value() {
value="$(get_value "$1")" || return 1
[ -n "$value" ] || return 1
printf '%s\n' "$value"
}
has_value() {
get_required_value "$1" >/dev/null 2>&1
}
authkey_update_file() (
dir="$(secrets_dir)"
update_file="$(mktemp "$dir/.luci-secrets-update.XXXXXX")"
trap 'rm -f "$update_file"' 0 1 2 15
"$JQ_CMD" -Rse 'select(length > 0) | {authkey: ., authkey_pending: true}' >"$update_file" || {
rm -f "$update_file"
fail "auth key must not be empty"
}
printf '%s\n' "$update_file"
trap - 0 1 2 15
)
adguard_update_file() (
dir="$(secrets_dir)"
payload_file="$(mktemp "$dir/.luci-secrets-payload.XXXXXX")"
update_file="$(mktemp "$dir/.luci-secrets-update.XXXXXX")"
trap 'rm -f "$payload_file" "$update_file"' 0 1 2 15
cat >"$payload_file"
"$JQ_CMD" -e '
type == "object" and
(.password | type == "string" and length > 0) and
(.api_url | type == "string") and
(.username | type == "string")
' "$payload_file" >/dev/null 2>&1 || fail "invalid AdGuard credential payload"
api_url="$("$JQ_CMD" -er '.api_url' "$payload_file")" || fail "invalid AdGuard credential payload"
[ -n "$api_url" ] || fail "invalid AdGuard credential payload"
normalized_api_url="$(normalize_api_url "$api_url")" || fail "invalid AdGuard credential payload"
"$JQ_CMD" -ce --arg api_url "$normalized_api_url" '
if (
type == "object" and
(.password | type == "string" and length > 0) and
(.api_url | type == "string") and
(.username | type == "string")
) then {
adguard_password: .password,
adguard_api_url: $api_url,
adguard_username: .username
} else error("invalid AdGuard credential payload") end
' "$payload_file" >"$update_file" || {
rm -f "$update_file"
fail "invalid AdGuard credential payload"
}
rm -f "$payload_file"
printf '%s\n' "$update_file"
trap - 0 1 2 15
)
batch_update_file() (
dir="$(secrets_dir)"
payload_file="$(mktemp "$dir/.luci-secrets-payload.XXXXXX")"
update_file="$(mktemp "$dir/.luci-secrets-update.XXXXXX")"
trap 'rm -f "$payload_file" "$update_file"' 0 1 2 15
cat >"$payload_file"
"$JQ_CMD" -e '
type == "object" and
((.authkey == null) or (.authkey | type == "string" and length > 0)) and
((.adguard == null) or (
(.adguard | type == "object") and
(.adguard.password | type == "string" and length > 0) and
(.adguard.api_url | type == "string") and
(.adguard.username | type == "string")
)) and
((.authkey != null) or (.adguard != null))
' "$payload_file" >/dev/null 2>&1 || fail "invalid credential batch payload"
has_adguard="$("$JQ_CMD" -r 'if .adguard == null then "0" else "1" end' "$payload_file")" || \
fail "invalid credential batch payload"
normalized_api_url=
if [ "$has_adguard" = "1" ]; then
api_url="$("$JQ_CMD" -er '.adguard.api_url' "$payload_file")" || fail "invalid credential batch payload"
[ -n "$api_url" ] || fail "invalid credential batch payload"
normalized_api_url="$(normalize_api_url "$api_url")" || fail "invalid credential batch payload"
fi
"$JQ_CMD" -ce --arg api_url "$normalized_api_url" '
if (
type == "object" and
((.authkey == null) or (.authkey | type == "string" and length > 0)) and
((.adguard == null) or (
(.adguard | type == "object") and
(.adguard.password | type == "string" and length > 0) and
(.adguard.api_url | type == "string") and
(.adguard.username | type == "string")
)) and
((.authkey != null) or (.adguard != null))
) then
(if .authkey == null then {} else {authkey: .authkey, authkey_pending: true} end) +
(if .adguard == null then {} else {
adguard_password: .adguard.password,
adguard_api_url: $api_url,
adguard_username: .adguard.username
} end)
else error("invalid credential batch payload") end
' "$payload_file" >"$update_file" || {
rm -f "$update_file"
fail "invalid credential batch payload"
}
rm -f "$payload_file"
printf '%s\n' "$update_file"
trap - 0 1 2 15
)
set_authkey() {
update_file="$(authkey_update_file)"
trap 'rm -f "$update_file"' 0 1 2 15
ref="$(stage_update "$update_file")"
activate_and_persist_ref "$ref"
rm -f "$update_file"
trap - 0 1 2 15
}
stage_authkey() {
base_ref="${1:-}"
update_file="$(authkey_update_file)"
trap 'rm -f "$update_file"' 0 1 2 15
stage_update "$update_file" "$base_ref"
rm -f "$update_file"
trap - 0 1 2 15
}
set_adguard() {
update_file="$(adguard_update_file)"
trap 'rm -f "$update_file"' 0 1 2 15
ref="$(stage_update "$update_file")"
activate_and_persist_ref "$ref"
rm -f "$update_file"
trap - 0 1 2 15
}
set_batch() {
update_file="$(batch_update_file)"
trap 'rm -f "$update_file"' 0 1 2 15
ref="$(stage_update "$update_file")"
activate_and_persist_ref "$ref"
rm -f "$update_file"
trap - 0 1 2 15
}
stage_batch() {
base_ref="${1:-}"
update_file="$(batch_update_file)"
trap 'rm -f "$update_file"' 0 1 2 15
stage_update "$update_file" "$base_ref"
rm -f "$update_file"
trap - 0 1 2 15
}
authkey_pending() {
ensure_store
"$JQ_CMD" -e '
.versions[.active_ref] |
(.authkey_pending == true) and
(.authkey | type == "string" and length > 0)
' "$SECRETS_FILE" >/dev/null 2>&1
}
clear_authkey() (
ensure_store
dir="$(secrets_dir)"
clear_tmp="$(mktemp "$dir/.luci-secrets-clear-authkey.XXXXXX")"
cleanup_clear_authkey() {
rm -f "$clear_tmp"
}
trap cleanup_clear_authkey 0 1 2 15
"$JQ_CMD" -ce \
'.versions |= with_entries(.value |= del(.authkey, .authkey_pending))' \
"$SECRETS_FILE" >"$clear_tmp"
replace_store "$clear_tmp"
rm -f "$clear_tmp"
trap - 0 1 2 15
)
matches_adguard() {
requested_url="$(normalize_api_url "${1:-}")"
requested_user="${2:-}"
has_value adguard_password || return 1
bound_url="$(get_value adguard_api_url)" || return 1
bound_user="$(get_value adguard_username)" || return 1
[ "$requested_url" = "$(normalize_api_url "$bound_url")" ] && [ "$requested_user" = "$bound_user" ]
}
adguard_password_for() {
matches_adguard "${1:-}" "${2:-}" || return 1
get_required_value adguard_password
}
write_migration_update() (
authkey="$1"
password="$2"
api_url="$3"
username="$4"
auth_present="$5"
adguard_present="$6"
dir="$(secrets_dir)"
auth_file="$(mktemp "$dir/.luci-migrate-auth.XXXXXX")"
password_file="$(mktemp "$dir/.luci-migrate-password.XXXXXX")"
api_url_file="$(mktemp "$dir/.luci-migrate-url.XXXXXX")"
username_file="$(mktemp "$dir/.luci-migrate-user.XXXXXX")"
update_file="$(mktemp "$dir/.luci-secrets-update.XXXXXX")"
trap 'rm -f "$auth_file" "$password_file" "$api_url_file" "$username_file" "$update_file"' 0 1 2 15
printf '%s' "$authkey" >"$auth_file"
printf '%s' "$password" >"$password_file"
printf '%s' "$api_url" >"$api_url_file"
printf '%s' "$username" >"$username_file"
"$JQ_CMD" -nc \
--arg auth_present "$auth_present" \
--arg adguard_present "$adguard_present" \
--rawfile authkey "$auth_file" \
--rawfile password "$password_file" \
--rawfile api_url "$api_url_file" \
--rawfile username "$username_file" \
'(if $auth_present == "1" and ($authkey | length > 0) then {authkey:$authkey} else {} end) +
(if $adguard_present == "1" and ($password | length > 0) then {
adguard_password:$password,
adguard_api_url:$api_url,
adguard_username:$username
} else {} end)' >"$update_file"
rm -f "$auth_file" "$password_file" "$api_url_file" "$username_file"
printf '%s\n' "$update_file"
trap - 0 1 2 15
)
migrate_legacy() {
ensure_store
auth_present=0
adguard_present=0
legacy_authkey=
legacy_adguard_password=
if legacy_authkey="$("$UCI_CMD" -q get tailscale.settings.authkey 2>/dev/null)"; then
auth_present=1
fi
if legacy_adguard_password="$("$UCI_CMD" -q get tailscale.settings.adguard_password 2>/dev/null)"; then
adguard_present=1
fi
legacy_api_url="$("$UCI_CMD" -q get tailscale.settings.adguard_api_url 2>/dev/null || true)"
legacy_username="$("$UCI_CMD" -q get tailscale.settings.adguard_username 2>/dev/null || true)"
legacy_api_url="$(normalize_api_url "$legacy_api_url")"
ref="$(active_ref)"
if { [ "$auth_present" = "1" ] && [ -n "$legacy_authkey" ]; } || \
{ [ "$adguard_present" = "1" ] && [ -n "$legacy_adguard_password" ]; }; then
update_file="$(write_migration_update \
"$legacy_authkey" \
"$legacy_adguard_password" \
"$legacy_api_url" \
"$legacy_username" \
"$auth_present" \
"$adguard_present")"
trap 'rm -f "$update_file"' 0 1 2 15
ref="$(stage_update "$update_file" "$ref")"
rm -f "$update_file"
trap - 0 1 2 15
fi
configured_ref="$("$UCI_CMD" -q get tailscale.settings.secrets_ref 2>/dev/null || true)"
changed=0
if [ -z "$configured_ref" ] || [ "$auth_present" = "1" ] || [ "$adguard_present" = "1" ]; then
"$UCI_CMD" -q set "tailscale.settings.secrets_ref=$ref" || fail "failed to stage protected credential reference"
changed=1
fi
if [ "$auth_present" = "1" ]; then
"$UCI_CMD" -q delete tailscale.settings.authkey || true
changed=1
fi
if [ "$adguard_present" = "1" ]; then
"$UCI_CMD" -q delete tailscale.settings.adguard_password || true
changed=1
fi
[ "$changed" = "0" ] || "$UCI_CMD" commit tailscale || fail "failed to commit protected credential migration"
activate_ref "$ref"
}
command="${1:-}"
case "$command" in
get|has|active-ref|authkey-pending|clear-authkey|set-authkey|stage-authkey|set-adguard|set-batch|stage-batch|activate|matches-adguard|adguard-password-for|migrate)
lock_secrets
;;
*)
fail "usage: $0 get|has|active-ref|authkey-pending|clear-authkey|set-authkey|stage-authkey|set-adguard|set-batch|stage-batch|activate|matches-adguard|adguard-password-for|migrate"
;;
esac
case "$command" in
get)
get_required_value "${2:-}"
;;
has)
has_value "${2:-}"
;;
active-ref)
active_ref
;;
authkey-pending)
authkey_pending
;;
clear-authkey)
clear_authkey
;;
set-authkey)
set_authkey
;;
stage-authkey)
stage_authkey "${2:-}"
;;
set-adguard)
set_adguard
;;
set-batch)
set_batch
;;
stage-batch)
stage_batch "${2:-}"
;;
activate)
activate_ref "${2:-}"
;;
matches-adguard)
matches_adguard "${2:-}" "${3:-}"
;;
adguard-password-for)
adguard_password_for "${2:-}" "${3:-}"
;;
migrate)
migrate_legacy
;;
esac
@@ -26,14 +26,6 @@
"path": "tailscale/interface"
}
},
"admin/vpn/tailscale/peers": {
"title": "Peers",
"order": 25,
"action": {
"type": "view",
"path": "tailscale/peers"
}
},
"admin/vpn/tailscale/log": {
"title": "Logs",
"order": 30,
@@ -4,23 +4,23 @@
"read": {
"file": {
"/sbin/ip -s -j ad": [ "exec" ],
"/usr/bin/ip -s -j ad": [ "exec" ],
"/sbin/logread -e tailscale": [ "exec" ],
"/usr/sbin/tailscale status --json": [ "exec" ],
"/usr/sbin/tailscale_peer_probe": [ "exec" ],
"/usr/sbin/tailscale version": [ "exec" ],
"/usr/sbin/tailscale login": [ "exec" ],
"/usr/sbin/tailscale logout": [ "exec" ]
"/usr/sbin/tailscale logout": [ "exec" ],
"/usr/sbin/tailscale_helper": [ "exec" ],
"/usr/sbin/tailscale_helper check_update": [ "exec" ],
"/usr/sbin/tailscale_helper do_update": [ "exec" ]
},
"ubus": {
"luci.tailscale": [ "secret_status", "openclash_bypass_status", "policy_routing_status" ],
"service": [ "list" ]
},
"uci": [ "tailscale", "tailscale_openclash", "tailscale_policy_routing" ]
"uci": [ "tailscale" ]
},
"write": {
"ubus": {
"luci.tailscale": [ "adguard_preflight", "set_secrets" ]
},
"uci": [ "tailscale", "tailscale_openclash", "tailscale_policy_routing" ]
"uci": [ "tailscale" ]
}
}
}
-40
View File
@@ -1,40 +0,0 @@
const fs = require('fs');
const vm = require('vm');
const source = fs.readFileSync('htdocs/luci-static/resources/view/tailscale/log.js', 'utf8');
const helperSource = source.split('return view.extend')[0]
.split('\n')
.filter(line => !line.startsWith("'require ") && !line.startsWith("'use strict'"))
.join('\n');
const context = {
module: { exports: {} },
console
};
vm.runInNewContext(`${helperSource}\nmodule.exports = { formatLogLines, formatLogError };`, context);
const { formatLogLines, formatLogError } = context.module.exports;
function assert(condition, message) {
if (!condition)
throw new Error(message);
}
let result = formatLogLines('');
assert(result.value === 'Log is empty.', `unexpected empty log value: ${result.value}`);
assert(result.rows === 2, `unexpected empty log rows: ${result.rows}`);
result = formatLogError(new Error('Failed to find log object: Not found'));
assert(
result.value === 'Unable to load log data: Failed to find log object: Not found',
`unexpected log error value: ${result.value}`
);
assert(result.rows === 2, `unexpected log error rows: ${result.rows}`);
const sample = 'Thu Jul 16 00:21:29 2026 daemon.notice tailscaled[123]: active\\n';
result = formatLogLines(sample);
assert(result.value.includes('[ Info ]'), `expected notice to map to Info: ${result.value}`);
assert(result.value.includes('active'), `expected message body: ${result.value}`);
console.log('log view tests passed');
@@ -1,789 +0,0 @@
#!/bin/sh
set -eu
ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
fail() {
echo "FAIL: $*" >&2
exit 1
}
assert_file() {
[ -f "$ROOT_DIR/$1" ] || fail "missing required package file: $1"
}
assert_contains() {
needle="$1"
file="$2"
grep -F -- "$needle" "$ROOT_DIR/$file" >/dev/null || fail "$file should contain: $needle"
}
assert_po_entry() {
expected_msgid="$1"
expected_msgstr="$2"
file="$3"
case "$file" in
/*) po_file="$file" ;;
*) po_file="$ROOT_DIR/$file" ;;
esac
awk -v expected_msgid="$expected_msgid" -v expected_msgstr="$expected_msgstr" '
function quoted_value(line, value) {
value = line
sub(/^[^"]*"/, "", value)
sub(/"$/, "", value)
return value
}
function finish_entry() {
if (has_msgid && has_msgstr && msgid == expected_msgid && msgstr == expected_msgstr)
matched = 1
msgid = ""
msgstr = ""
active_field = ""
has_msgid = 0
has_msgstr = 0
}
/^[[:space:]]*$/ {
finish_entry()
next
}
/^msgid "/ {
if (has_msgid || has_msgstr)
finish_entry()
msgid = quoted_value($0)
has_msgid = 1
active_field = "msgid"
next
}
/^msgstr "/ {
msgstr = quoted_value($0)
has_msgstr = 1
active_field = "msgstr"
next
}
/^"/ {
if (active_field == "msgid")
msgid = msgid quoted_value($0)
else if (active_field == "msgstr")
msgstr = msgstr quoted_value($0)
next
}
{
active_field = ""
}
END {
finish_entry()
exit !matched
}
' "$po_file" || fail "$file should contain PO entry: msgid $expected_msgid with msgstr $expected_msgstr"
}
assert_po_entry_mutation_test() {
mutated_po="$(mktemp "$ROOT_DIR/tests/package_release_test.XXXXXX")"
trap 'rm -f "$mutated_po"' 0 HUP INT TERM
awk '
$0 == "msgstr \"已开启,4 条绕过规则已生效\"" { print "msgstr \"已关闭,Tailscale 流量将由 OpenClash 处理\""; next }
$0 == "msgstr \"已关闭,Tailscale 流量将由 OpenClash 处理\"" { print "msgstr \"已开启,4 条绕过规则已生效\""; next }
{ print }
' "$ROOT_DIR/po/zh_Hans/tailscale.po" > "$mutated_po"
if (assert_po_entry "Enabled; 4 bypass rules are active" "已开启,4 条绕过规则已生效" "$mutated_po") >/dev/null 2>&1; then
fail "assert_po_entry must reject swapped PO translations"
fi
rm -f "$mutated_po"
}
assert_not_contains() {
needle="$1"
file="$2"
if grep -F -- "$needle" "$ROOT_DIR/$file" >/dev/null; then
fail "$file should not contain: $needle"
fi
}
assert_jq() {
filter="$1"
file="$2"
message="$3"
jq -e "$filter" "$ROOT_DIR/$file" >/dev/null || fail "$message"
}
line_number() {
needle="$1"
file="$2"
grep -nF -- "$needle" "$ROOT_DIR/$file" | head -n 1 | cut -d: -f1
}
assert_before() {
first="$1"
second="$2"
file="$3"
first_line="$(line_number "$first" "$file")"
second_line="$(line_number "$second" "$file")"
[ -n "$first_line" ] || fail "$file should contain: $first"
[ -n "$second_line" ] || fail "$file should contain: $second"
[ "$first_line" -lt "$second_line" ] || fail "$file should place '$first' before '$second'"
}
assert_peer_pagination_after_table() {
file="$1"
awk '
/return E\('\''div'\'', \{ class: '\''cbi-map'\'' \}/ { in_return = 1 }
in_return && /E\('\''table'\'', \{/ { table_line = NR }
in_return && /^[[:space:]]*paginationBox[[:space:]]*$/ { pagination_line = NR }
END {
if (table_line && pagination_line && table_line < pagination_line)
exit 0
exit 1
}
' "$ROOT_DIR/$file" || fail "$file should render peer pagination below the peer table"
}
assert_not_exists() {
[ ! -e "$ROOT_DIR/$1" ] || fail "$1 should not exist"
}
assert_no_active_config_list() {
option="$1"
file="$2"
if grep -E "^[[:space:]]*list[[:space:]]+$option([[:space:]]|$)" "$ROOT_DIR/$file" >/dev/null; then
fail "$file must not ship an active $option default"
fi
}
assert_release_permissions() {
file="$1"
awk '
/^permissions:$/ {
global_permissions = 1
next
}
global_permissions && /^ contents: / {
global_contents = $2
if ($2 == "write") {
write_count++
write_outside_release = 1
}
global_permissions = 0
next
}
/^jobs:$/ {
in_jobs = 1
next
}
in_jobs && /^ [A-Za-z0-9_-]+:$/ {
job = $1
sub(/:$/, "", job)
job_permissions = 0
next
}
in_jobs && /^ permissions:$/ {
job_permissions = 1
next
}
in_jobs && job_permissions && /^ contents: / {
job_contents[job] = $2
if ($2 == "write") {
write_count++
if (job != "release")
write_outside_release = 1
}
job_permissions = 0
next
}
END {
if (global_contents != "read")
exit 1
if (job_contents["release"] != "write")
exit 1
if (job_contents["test"] == "write" || job_contents["build"] == "write")
exit 1
if (write_count != 1 || write_outside_release)
exit 1
}
' "$ROOT_DIR/$file" || fail "$file should grant contents: read globally and contents: write only to the release job"
}
grep -Eq '^PKG_VERSION:=[0-9]+\.[0-9]+\.[0-9]+$' "$ROOT_DIR/Makefile" || \
fail 'Makefile should declare a semantic package version'
assert_file .github/workflows/release.yml
assert_release_permissions .github/workflows/release.yml
assert_contains "tags:" .github/workflows/release.yml
assert_contains "v*" .github/workflows/release.yml
assert_contains "pull_request:" .github/workflows/release.yml
assert_contains "branches:" .github/workflows/release.yml
assert_contains "main" .github/workflows/release.yml
assert_contains "test:" .github/workflows/release.yml
assert_contains "for test in tests/*_test.sh" .github/workflows/release.yml
assert_contains "for test in tests/*_test.js" .github/workflows/release.yml
assert_contains "for file in root/etc/init.d/tailscale" .github/workflows/release.yml
assert_contains "for file in htdocs/luci-static/resources/view/tailscale/*.js tests/*_test.js" .github/workflows/release.yml
assert_contains "sh -n" .github/workflows/release.yml
assert_contains "node --check" .github/workflows/release.yml
assert_contains "jq -e" .github/workflows/release.yml
assert_contains "msgfmt --check-format" .github/workflows/release.yml
assert_contains "needs: test" .github/workflows/release.yml
assert_contains "sdk_arch: x86_64-24.10.5@sha256:" .github/workflows/release.yml
assert_contains "sdk_arch: x86_64@sha256:" .github/workflows/release.yml
assert_contains "if: startsWith(github.ref, 'refs/tags/')" .github/workflows/release.yml
assert_contains "luci-app-tailscale_*.ipk" .github/workflows/release.yml
assert_contains "luci-app-tailscale-*.apk" .github/workflows/release.yml
assert_file htdocs/luci-static/resources/view/tailscale/setting.js
assert_file tests/setting_preflight_test.js
assert_contains "refreshAdguardPreflightStatus" htdocs/luci-static/resources/view/tailscale/setting.js
assert_file root/etc/config/tailscale
assert_no_active_config_list adguard_default_upstreams root/etc/config/tailscale
assert_no_active_config_list adguard_tailnet_upstreams root/etc/config/tailscale
assert_not_contains "litata.com" root/etc/config/tailscale
assert_not_contains "litata.tailnet" root/etc/config/tailscale
assert_file root/etc/init.d/tailscale
assert_file root/usr/sbin/tailscale_helper
assert_file root/usr/sbin/tailscale_keepalive
assert_file root/usr/sbin/tailscale_adguard_dns_switch
assert_file root/usr/sbin/tailscale_openclash_bypass
assert_file root/etc/config/tailscale_openclash
assert_file root/etc/init.d/tailscale-openclash-bypass
assert_file root/etc/config/tailscale_policy_routing
assert_file root/etc/init.d/tailscale-policy-routing
assert_file root/etc/hotplug.d/iface/98-tailscale-policy-routing
assert_file root/usr/sbin/tailscale_policy_routing
assert_file root/usr/sbin/tailscale_peer_probe
assert_file root/usr/sbin/tailscale_secrets
assert_file root/lib/upgrade/keep.d/luci-app-tailscale
assert_file root/usr/libexec/rpcd/luci.tailscale
assert_file root/usr/share/rpcd/acl.d/luci-app-tailscale.json
assert_contains "Peer probe" root/usr/sbin/tailscale_peer_probe
assert_contains "LUCI_DEPENDS:=+tailscale +jshn +curl +jq +flock" Makefile
assert_contains "allow_wan_direct" root/etc/config/tailscale
assert_contains "wan_direct_zones" root/etc/config/tailscale
assert_contains "allow_wan_direct" htdocs/luci-static/resources/view/tailscale/setting.js
assert_contains "wan_direct_zones" htdocs/luci-static/resources/view/tailscale/setting.js
assert_contains "Allow WAN Direct" htdocs/luci-static/resources/view/tailscale/setting.js
assert_contains "WAN Direct Source Zones" htdocs/luci-static/resources/view/tailscale/setting.js
assert_contains "ALLOW_WAN_DIRECT" root/etc/init.d/tailscale
assert_contains "WAN_DIRECT_ZONES" root/etc/init.d/tailscale
assert_contains "TAILSCALE_PORT" root/etc/init.d/tailscale
assert_contains "firewall.ts_wan_direct" root/usr/sbin/tailscale_helper
assert_contains "Allow-Tailscale-WAN-" root/usr/sbin/tailscale_helper
assert_contains "WAN_DIRECT_ZONES" root/usr/sbin/tailscale_helper
assert_contains 'firewall.$section.src=$zone' root/usr/sbin/tailscale_helper
assert_contains 'firewall.$section.proto=udp' root/usr/sbin/tailscale_helper
assert_contains 'firewall.$section.dest_port=$port' root/usr/sbin/tailscale_helper
assert_contains 'firewall.$section.target=ACCEPT' root/usr/sbin/tailscale_helper
assert_not_contains "tailscale_openclash_bypass" root/usr/sbin/tailscale_helper
assert_not_contains "openclash_custom_firewall_rules.sh" root/usr/sbin/tailscale_helper
assert_contains 'POLICY_ROUTING_HELPER' root/usr/sbin/tailscale_helper
assert_contains '"$POLICY_ROUTING_HELPER" sync' root/usr/sbin/tailscale_helper
assert_not_contains '/etc/config/firewall' root/usr/sbin/tailscale_policy_routing
assert_not_contains 'uci commit firewall' root/usr/sbin/tailscale_policy_routing
assert_not_contains '/etc/init.d/firewall' root/usr/sbin/tailscale_policy_routing
assert_not_contains '/etc/init.d/openclash' root/usr/sbin/tailscale_policy_routing
assert_not_contains '"$MWAN3_INIT" reload' root/usr/sbin/tailscale_policy_routing
assert_not_contains '"$MWAN3_INIT" restart' root/usr/sbin/tailscale_policy_routing
assert_not_contains '"$MWAN3_INIT" start' root/usr/sbin/tailscale_policy_routing
assert_not_contains '"$MWAN3_INIT" stop' root/usr/sbin/tailscale_policy_routing
assert_not_contains 'mwan3 restart' root/usr/sbin/tailscale_policy_routing
assert_contains 'network.ts_mwan3_table52' root/usr/sbin/tailscale_policy_routing
assert_contains "PRIORITY='1000'" root/usr/sbin/tailscale_policy_routing
assert_contains "TABLE='52'" root/usr/sbin/tailscale_policy_routing
assert_not_contains "/etc/config/firewall" root/usr/sbin/tailscale_openclash_bypass
assert_not_contains "uci commit firewall" root/usr/sbin/tailscale_openclash_bypass
assert_not_contains "/etc/init.d/firewall" root/usr/sbin/tailscale_openclash_bypass
assert_not_contains "/etc/init.d/openclash reload" root/usr/sbin/tailscale_openclash_bypass
assert_not_contains "/etc/init.d/openclash restart" root/usr/sbin/tailscale_openclash_bypass
assert_contains 'msgid "OpenClash"' po/templates/tailscale.pot
assert_contains 'msgid "Protect Tailscale Traffic (Bypass OpenClash)"' po/templates/tailscale.pot
assert_contains 'msgid "Status"' po/templates/tailscale.pot
assert_contains 'msgid "Checking ..."' po/templates/tailscale.pot
assert_contains 'msgid "Enabled; 4 bypass rules are active"' po/templates/tailscale.pot
assert_contains 'msgid "Enabled; waiting for OpenClash nftables chains"' po/templates/tailscale.pot
assert_contains 'msgid "Disabled; Tailscale traffic is handled by OpenClash"' po/templates/tailscale.pot
assert_contains 'msgid "OpenClash is not installed; bypass is not required"' po/templates/tailscale.pot
assert_contains 'msgid "Unsupported; firewall4/nftables is required"' po/templates/tailscale.pot
assert_contains 'msgid "Configuration error"' po/templates/tailscale.pot
assert_contains 'msgid "Unknown status"' po/templates/tailscale.pot
assert_contains 'msgid "Unable to read OpenClash bypass status."' po/templates/tailscale.pot
assert_contains 'msgid "Keep Tailscale control connections, direct connections, Tailnet DNS, and subnet traffic outside OpenClash. When disabled, this traffic is handled by OpenClash; node connectivity, direct paths, subnet access, and internal DNS are no longer protected by this feature. Keep this enabled while using OpenClash."' po/templates/tailscale.pot
assert_contains 'msgid "Prioritize Tailscale Routes Before mwan3"' po/templates/tailscale.pot
assert_contains 'msgid "Policy Routing Status"' po/templates/tailscale.pot
assert_contains 'msgid "Enabled; mwan3 is not installed"' po/templates/tailscale.pot
assert_contains 'msgid "Enabled; no earlier mwan3-marked rule was detected"' po/templates/tailscale.pot
assert_contains 'msgid "Enabled; Tailscale routes take precedence over mwan3"' po/templates/tailscale.pot
assert_contains 'msgid "Unable to read Tailscale policy-routing status."' po/templates/tailscale.pot
assert_po_entry "OpenClash" "OpenClash" po/zh_Hans/tailscale.po
assert_po_entry "Protect Tailscale Traffic (Bypass OpenClash)" "保护 Tailscale 流量(绕过 OpenClash)" po/zh_Hans/tailscale.po
assert_po_entry "Status" "状态" po/zh_Hans/tailscale.po
assert_po_entry "Checking ..." "检查中..." po/zh_Hans/tailscale.po
assert_po_entry "Enabled; 4 bypass rules are active" "已开启,4 条绕过规则已生效" po/zh_Hans/tailscale.po
assert_po_entry "Enabled; waiting for OpenClash nftables chains" "已开启,正在等待 OpenClash 创建 nftables 链" po/zh_Hans/tailscale.po
assert_po_entry "Disabled; Tailscale traffic is handled by OpenClash" "已关闭,Tailscale 流量将由 OpenClash 处理" po/zh_Hans/tailscale.po
assert_po_entry "OpenClash is not installed; bypass is not required" "未安装 OpenClash,无需绕过" po/zh_Hans/tailscale.po
assert_po_entry "Unsupported; firewall4/nftables is required" "当前系统不支持,仅支持 firewall4/nftables" po/zh_Hans/tailscale.po
assert_po_entry "Configuration error" "配置错误" po/zh_Hans/tailscale.po
assert_po_entry "Unknown status" "未知状态" po/zh_Hans/tailscale.po
assert_po_entry "Unable to read OpenClash bypass status." "无法读取 OpenClash 绕过状态。" po/zh_Hans/tailscale.po
assert_po_entry "Keep Tailscale control connections, direct connections, Tailnet DNS, and subnet traffic outside OpenClash. When disabled, this traffic is handled by OpenClash; node connectivity, direct paths, subnet access, and internal DNS are no longer protected by this feature. Keep this enabled while using OpenClash." "开启后,Tailscale 控制连接、直连通信、Tailnet DNS 和跨子网流量不会被 OpenClash 接管。关闭后,这些流量将重新经过 OpenClash,节点在线、点对点直连、跨子网访问和 Tailnet DNS 解析不再受本功能保护;如果 OpenClash 规则接管或重定向这些流量,可能导致节点掉线、直连退化为 DERP、跨子网访问或 Tailnet DNS 中断。使用 OpenClash 时必须保持开启。" po/zh_Hans/tailscale.po
assert_po_entry "Prioritize Tailscale Routes Before mwan3" "优先让 Tailscale 路由先于 mwan3" po/zh_Hans/tailscale.po
assert_po_entry "Policy Routing Status" "策略路由状态" po/zh_Hans/tailscale.po
assert_po_entry "Enabled; mwan3 is not installed" "已开启,未安装 mwan3" po/zh_Hans/tailscale.po
assert_po_entry "Enabled; no earlier mwan3-marked rule was detected" "已开启,未检测到更早的 mwan3 标记规则" po/zh_Hans/tailscale.po
assert_po_entry "Enabled; Tailscale routes take precedence over mwan3" "已开启,Tailscale 路由优先于 mwan3" po/zh_Hans/tailscale.po
assert_po_entry "Unable to read Tailscale policy-routing status." "无法读取 Tailscale 策略路由状态。" po/zh_Hans/tailscale.po
assert_po_entry "OpenClash" "OpenClash" po/zh_Hant/tailscale.po
assert_po_entry "Protect Tailscale Traffic (Bypass OpenClash)" "保護 Tailscale 流量(繞過 OpenClash)" po/zh_Hant/tailscale.po
assert_po_entry "Status" "狀態" po/zh_Hant/tailscale.po
assert_po_entry "Checking ..." "檢查中..." po/zh_Hant/tailscale.po
assert_po_entry "Enabled; 4 bypass rules are active" "已開啟,4 條繞過規則已生效" po/zh_Hant/tailscale.po
assert_po_entry "Enabled; waiting for OpenClash nftables chains" "已開啟,正在等待 OpenClash 建立 nftables 鏈" po/zh_Hant/tailscale.po
assert_po_entry "Disabled; Tailscale traffic is handled by OpenClash" "已關閉,Tailscale 流量將由 OpenClash 處理" po/zh_Hant/tailscale.po
assert_po_entry "OpenClash is not installed; bypass is not required" "未安裝 OpenClash,無需繞過" po/zh_Hant/tailscale.po
assert_po_entry "Unsupported; firewall4/nftables is required" "目前系統不支援,僅支援 firewall4/nftables" po/zh_Hant/tailscale.po
assert_po_entry "Configuration error" "設定錯誤" po/zh_Hant/tailscale.po
assert_po_entry "Unknown status" "未知狀態" po/zh_Hant/tailscale.po
assert_po_entry "Unable to read OpenClash bypass status." "無法讀取 OpenClash 繞過狀態。" po/zh_Hant/tailscale.po
assert_po_entry "Keep Tailscale control connections, direct connections, Tailnet DNS, and subnet traffic outside OpenClash. When disabled, this traffic is handled by OpenClash; node connectivity, direct paths, subnet access, and internal DNS are no longer protected by this feature. Keep this enabled while using OpenClash." "開啟後,Tailscale 控制連線、直連通訊、Tailnet DNS 和跨子網流量不會被 OpenClash 接管。關閉後,這些流量將重新經過 OpenClash,節點在線、點對點直連、跨子網存取和 Tailnet DNS 解析不再受本功能保護;如果 OpenClash 規則接管或重新導向這些流量,可能導致節點離線、直連退化為 DERP、跨子網存取或 Tailnet DNS 中斷。使用 OpenClash 時必須保持開啟。" po/zh_Hant/tailscale.po
assert_po_entry "Prioritize Tailscale Routes Before mwan3" "優先讓 Tailscale 路由先於 mwan3" po/zh_Hant/tailscale.po
assert_po_entry "Policy Routing Status" "策略路由狀態" po/zh_Hant/tailscale.po
assert_po_entry "Enabled; mwan3 is not installed" "已開啟,未安裝 mwan3" po/zh_Hant/tailscale.po
assert_po_entry "Enabled; no earlier mwan3-marked rule was detected" "已開啟,未偵測到較早的 mwan3 標記規則" po/zh_Hant/tailscale.po
assert_po_entry "Enabled; Tailscale routes take precedence over mwan3" "已開啟,Tailscale 路由優先於 mwan3" po/zh_Hant/tailscale.po
assert_po_entry "Unable to read Tailscale policy-routing status." "無法讀取 Tailscale 策略路由狀態。" po/zh_Hant/tailscale.po
assert_po_entry_mutation_test
assert_contains 'msgid "Allow WAN Direct"' po/templates/tailscale.pot
assert_contains 'msgid "WAN Direct Source Zones"' po/templates/tailscale.pot
assert_contains 'msgid "Allow WAN Direct"' po/zh_Hans/tailscale.po
assert_contains 'msgstr "允许 WAN 直连"' po/zh_Hans/tailscale.po
assert_contains 'msgid "WAN Direct Source Zones"' po/zh_Hans/tailscale.po
assert_contains 'msgstr "WAN 直连来源区域"' po/zh_Hans/tailscale.po
assert_contains 'msgid "Allow WAN Direct"' po/zh_Hant/tailscale.po
assert_contains 'msgstr "允許 WAN 直連"' po/zh_Hant/tailscale.po
assert_contains 'msgid "WAN Direct Source Zones"' po/zh_Hant/tailscale.po
assert_contains 'msgstr "WAN 直連來源區域"' po/zh_Hant/tailscale.po
assert_contains '"$JQ_BIN" -nc' root/usr/sbin/tailscale_peer_probe
assert_contains "--argjson ok" root/usr/sbin/tailscale_peer_probe
assert_contains "ping --c=1 --timeout=2s" root/usr/sbin/tailscale_peer_probe
assert_not_contains "json_escape()" root/usr/sbin/tailscale_peer_probe
assert_file root/etc/uci-defaults/40_luci-tailscale
assert_file tests/tailscale_legacy_adguard_migration_test.sh
assert_contains '"admin/vpn/tailscale/peers"' root/usr/share/luci/menu.d/luci-app-tailscale.json
assert_contains '"title": "Peers"' root/usr/share/luci/menu.d/luci-app-tailscale.json
assert_contains '"path": "tailscale/peers"' root/usr/share/luci/menu.d/luci-app-tailscale.json
assert_before '"admin/vpn/tailscale/interface"' '"admin/vpn/tailscale/peers"' root/usr/share/luci/menu.d/luci-app-tailscale.json
assert_before '"admin/vpn/tailscale/peers"' '"admin/vpn/tailscale/log"' root/usr/share/luci/menu.d/luci-app-tailscale.json
assert_contains 'msgid "Peers"' po/templates/tailscale.pot
assert_contains 'msgid "Peers"' po/zh_Hans/tailscale.po
assert_contains 'msgstr "对端列表"' po/zh_Hans/tailscale.po
assert_contains 'msgid "Peers"' po/zh_Hant/tailscale.po
assert_contains 'msgstr "對端列表"' po/zh_Hant/tailscale.po
assert_contains 'msgid "Tailscale Peers"' po/templates/tailscale.pot
assert_contains 'msgid "Tailscale Peers"' po/zh_Hans/tailscale.po
assert_contains 'msgstr "Tailscale 对端"' po/zh_Hans/tailscale.po
assert_contains 'msgid "Tailscale Peers"' po/zh_Hant/tailscale.po
assert_contains 'msgstr "Tailscale 對端"' po/zh_Hant/tailscale.po
assert_contains 'msgid "View all peers and manually probe whether traffic is direct or relayed through DERP."' po/templates/tailscale.pot
assert_contains 'msgid "View all peers and manually probe whether traffic is direct or relayed through DERP."' po/zh_Hans/tailscale.po
assert_contains 'msgstr "查看所有对端,并手动探测流量是直连还是通过 DERP 中继。"' po/zh_Hans/tailscale.po
assert_contains 'msgid "View all peers and manually probe whether traffic is direct or relayed through DERP."' po/zh_Hant/tailscale.po
assert_contains 'msgstr "查看所有對端,並手動探測流量是直連還是透過 DERP 中繼。"' po/zh_Hant/tailscale.po
assert_contains 'msgid "Unable to load Tailscale peer status"' po/templates/tailscale.pot
assert_contains 'msgid "Unable to load Tailscale peer status"' po/zh_Hans/tailscale.po
assert_contains 'msgstr "无法加载 Tailscale 对端状态。"' po/zh_Hans/tailscale.po
assert_contains 'msgid "Unable to load Tailscale peer status"' po/zh_Hant/tailscale.po
assert_contains 'msgstr "無法載入 Tailscale 對端狀態。"' po/zh_Hant/tailscale.po
assert_contains 'msgid "No peers match the selected filter."' po/templates/tailscale.pot
assert_contains 'msgid "No peers match the selected filter."' po/zh_Hans/tailscale.po
assert_contains 'msgstr "没有对端符合所选筛选条件。"' po/zh_Hans/tailscale.po
assert_contains 'msgid "No peers match the selected filter."' po/zh_Hant/tailscale.po
assert_contains 'msgstr "沒有對端符合所選篩選條件。"' po/zh_Hant/tailscale.po
assert_contains "No peers found" htdocs/luci-static/resources/view/tailscale/peers.js
assert_contains 'msgid "No peers found"' po/templates/tailscale.pot
assert_contains 'msgid "No peers found"' po/zh_Hans/tailscale.po
assert_contains 'msgstr "没有找到对端"' po/zh_Hans/tailscale.po
assert_contains 'msgid "No peers found"' po/zh_Hant/tailscale.po
assert_contains 'msgstr "沒有找到對端"' po/zh_Hant/tailscale.po
assert_contains 'msgid "No probe target available"' po/templates/tailscale.pot
assert_contains 'msgid "No probe target available"' po/zh_Hans/tailscale.po
assert_contains 'msgstr "没有可用探测目标。"' po/zh_Hans/tailscale.po
assert_contains 'msgid "No probe target available"' po/zh_Hant/tailscale.po
assert_contains 'msgstr "沒有可用探測目標。"' po/zh_Hant/tailscale.po
assert_contains 'msgid "Probe failed"' po/templates/tailscale.pot
assert_contains 'msgid "Probe failed"' po/zh_Hans/tailscale.po
assert_contains 'msgstr "探测失败"' po/zh_Hans/tailscale.po
assert_contains 'msgid "Probe failed"' po/zh_Hant/tailscale.po
assert_contains 'msgstr "探測失敗"' po/zh_Hant/tailscale.po
assert_contains 'msgid "Not probed"' po/templates/tailscale.pot
assert_contains 'msgid "Not probed"' po/zh_Hans/tailscale.po
assert_contains 'msgstr "未探测"' po/zh_Hans/tailscale.po
assert_contains 'msgid "Not probed"' po/zh_Hant/tailscale.po
assert_contains 'msgstr "未探測"' po/zh_Hant/tailscale.po
assert_contains 'msgid "Probing..."' po/templates/tailscale.pot
assert_contains 'msgid "Probing..."' po/zh_Hans/tailscale.po
assert_contains 'msgstr "探测中..."' po/zh_Hans/tailscale.po
assert_contains 'msgid "Probing..."' po/zh_Hant/tailscale.po
assert_contains 'msgstr "探測中..."' po/zh_Hant/tailscale.po
assert_contains 'msgid "Probe"' po/templates/tailscale.pot
assert_contains 'msgid "Probe"' po/zh_Hans/tailscale.po
assert_contains 'msgstr "探测"' po/zh_Hans/tailscale.po
assert_contains 'msgid "Probe"' po/zh_Hant/tailscale.po
assert_contains 'msgstr "探測"' po/zh_Hant/tailscale.po
assert_contains 'msgid "Direct"' po/templates/tailscale.pot
assert_contains 'msgid "Direct"' po/zh_Hans/tailscale.po
assert_contains 'msgstr "直连"' po/zh_Hans/tailscale.po
assert_contains 'msgid "Direct"' po/zh_Hant/tailscale.po
assert_contains 'msgstr "直連"' po/zh_Hant/tailscale.po
assert_contains 'msgid "DERP"' po/templates/tailscale.pot
assert_contains 'msgid "DERP"' po/zh_Hans/tailscale.po
assert_contains 'msgstr "DERP 中继"' po/zh_Hans/tailscale.po
assert_contains 'msgid "DERP"' po/zh_Hant/tailscale.po
assert_contains 'msgstr "DERP 中繼"' po/zh_Hant/tailscale.po
assert_contains 'msgid "Failed"' po/templates/tailscale.pot
assert_contains 'msgid "Failed"' po/zh_Hans/tailscale.po
assert_contains 'msgstr "失败"' po/zh_Hans/tailscale.po
assert_contains 'msgid "Failed"' po/zh_Hant/tailscale.po
assert_contains 'msgstr "失敗"' po/zh_Hant/tailscale.po
assert_contains 'msgid "Unknown"' po/templates/tailscale.pot
assert_contains 'msgid "Unknown"' po/zh_Hans/tailscale.po
assert_contains 'msgstr "未知"' po/zh_Hans/tailscale.po
assert_contains 'msgid "Unknown"' po/zh_Hant/tailscale.po
assert_contains 'msgstr "未知"' po/zh_Hant/tailscale.po
assert_contains 'msgid "Filter"' po/templates/tailscale.pot
assert_contains 'msgid "Filter"' po/zh_Hans/tailscale.po
assert_contains 'msgstr "筛选"' po/zh_Hans/tailscale.po
assert_contains 'msgid "Filter"' po/zh_Hant/tailscale.po
assert_contains 'msgstr "篩選"' po/zh_Hant/tailscale.po
assert_contains 'msgid "All"' po/templates/tailscale.pot
assert_contains 'msgid "All"' po/zh_Hans/tailscale.po
assert_contains 'msgstr "全部"' po/zh_Hans/tailscale.po
assert_contains 'msgid "All"' po/zh_Hant/tailscale.po
assert_contains 'msgstr "全部"' po/zh_Hant/tailscale.po
assert_contains 'msgid "Online"' po/templates/tailscale.pot
assert_contains 'msgid "Online"' po/zh_Hans/tailscale.po
assert_contains 'msgstr "在线"' po/zh_Hans/tailscale.po
assert_contains 'msgid "Online"' po/zh_Hant/tailscale.po
assert_contains 'msgstr "在線"' po/zh_Hant/tailscale.po
assert_contains 'msgid "Offline"' po/templates/tailscale.pot
assert_contains 'msgid "Offline"' po/zh_Hans/tailscale.po
assert_contains 'msgstr "离线"' po/zh_Hans/tailscale.po
assert_contains 'msgid "Offline"' po/zh_Hant/tailscale.po
assert_contains 'msgstr "離線"' po/zh_Hant/tailscale.po
assert_contains 'msgid "Name"' po/templates/tailscale.pot
assert_contains 'msgid "Name"' po/zh_Hans/tailscale.po
assert_contains 'msgstr "名称"' po/zh_Hans/tailscale.po
assert_contains 'msgid "Name"' po/zh_Hant/tailscale.po
assert_contains 'msgstr "名稱"' po/zh_Hant/tailscale.po
assert_contains 'msgid "Tailnet IP"' po/templates/tailscale.pot
assert_contains 'msgid "Tailnet IP"' po/zh_Hans/tailscale.po
assert_contains 'msgstr "Tailnet IP"' po/zh_Hans/tailscale.po
assert_contains 'msgid "Tailnet IP"' po/zh_Hant/tailscale.po
assert_contains 'msgstr "Tailnet IP"' po/zh_Hant/tailscale.po
assert_contains 'msgid "Last Seen"' po/templates/tailscale.pot
assert_contains 'msgid "Last Seen"' po/zh_Hans/tailscale.po
assert_contains 'msgstr "最后在线"' po/zh_Hans/tailscale.po
assert_contains 'msgid "Last Seen"' po/zh_Hant/tailscale.po
assert_contains 'msgstr "最後在線"' po/zh_Hant/tailscale.po
assert_contains 'msgid "Role"' po/templates/tailscale.pot
assert_contains 'msgid "Role"' po/zh_Hans/tailscale.po
assert_contains 'msgstr "角色"' po/zh_Hans/tailscale.po
assert_contains 'msgid "Role"' po/zh_Hant/tailscale.po
assert_contains 'msgstr "角色"' po/zh_Hant/tailscale.po
assert_contains 'msgid "Exit node"' po/templates/tailscale.pot
assert_contains 'msgid "Exit node"' po/zh_Hans/tailscale.po
assert_contains 'msgstr "出口节点"' po/zh_Hans/tailscale.po
assert_contains 'msgid "Exit node"' po/zh_Hant/tailscale.po
assert_contains 'msgstr "出口節點"' po/zh_Hant/tailscale.po
assert_contains 'msgid "Advertising subnets"' po/templates/tailscale.pot
assert_contains 'msgid "Advertising subnets"' po/zh_Hans/tailscale.po
assert_contains 'msgstr "发布网段"' po/zh_Hans/tailscale.po
assert_contains 'msgid "Advertising subnets"' po/zh_Hant/tailscale.po
assert_contains 'msgstr "發布網段"' po/zh_Hant/tailscale.po
assert_contains 'msgid "Advertised Subnets"' po/templates/tailscale.pot
assert_contains 'msgid "Advertised Subnets"' po/zh_Hans/tailscale.po
assert_contains 'msgstr "已发布网段"' po/zh_Hans/tailscale.po
assert_contains 'msgid "Advertised Subnets"' po/zh_Hant/tailscale.po
assert_contains 'msgstr "已發布網段"' po/zh_Hant/tailscale.po
assert_contains "Peer Keepalive" htdocs/luci-static/resources/view/tailscale/setting.js
assert_contains "AdGuard DNS" htdocs/luci-static/resources/view/tailscale/setting.js
assert_before "AdGuard API URL" "_adguard_dns_status" htdocs/luci-static/resources/view/tailscale/setting.js
assert_before "AdGuard Username" "_adguard_dns_status" htdocs/luci-static/resources/view/tailscale/setting.js
assert_before "AdGuard Password" "_adguard_dns_status" htdocs/luci-static/resources/view/tailscale/setting.js
assert_before "_adguard_dns_status" "Enable AdGuard DNS Auto Switch" htdocs/luci-static/resources/view/tailscale/setting.js
assert_contains "ADGUARD_PREFLIGHT_CHECKS" htdocs/luci-static/resources/view/tailscale/setting.js
assert_contains "window.setTimeout(refreshAdguardPreflightStatus" htdocs/luci-static/resources/view/tailscale/setting.js
assert_contains 'msgid "Checking ..."' po/templates/tailscale.pot
assert_po_entry "Checking ..." "检查中..." po/zh_Hans/tailscale.po
assert_po_entry "Checking ..." "檢查中..." po/zh_Hant/tailscale.po
assert_not_contains "AdGuard DNS auto switch cannot be enabled until every environment status check passes." htdocs/luci-static/resources/view/tailscale/setting.js
assert_contains "keepalivePeerAliases" htdocs/luci-static/resources/view/tailscale/setting.js
assert_contains "shortDnsName" htdocs/luci-static/resources/view/tailscale/setting.js
assert_contains "hasSubnetRoutes" htdocs/luci-static/resources/view/tailscale/setting.js
assert_contains "No subnet routes" htdocs/luci-static/resources/view/tailscale/setting.js
assert_contains "s.tab('keepalive'" htdocs/luci-static/resources/view/tailscale/setting.js
assert_contains "form.Value.extend" htdocs/luci-static/resources/view/tailscale/setting.js
assert_contains "KeepalivePeersValue, 'keepalive_peers'" htdocs/luci-static/resources/view/tailscale/setting.js
assert_contains "keepalive-peer-list" htdocs/luci-static/resources/view/tailscale/setting.js
assert_contains "Only peers advertising subnet routes are shown" htdocs/luci-static/resources/view/tailscale/setting.js
assert_contains "keepalive-peer-row" htdocs/luci-static/resources/view/tailscale/setting.js
assert_contains "max-width:680px;width:100%" htdocs/luci-static/resources/view/tailscale/setting.js
assert_contains "grid-template-columns:minmax(320px,1fr) minmax(140px,260px)" htdocs/luci-static/resources/view/tailscale/setting.js
assert_contains "min-height:42px" htdocs/luci-static/resources/view/tailscale/setting.js
assert_contains "keepalive-peer-main" htdocs/luci-static/resources/view/tailscale/setting.js
assert_contains "keepalive-peer-check" htdocs/luci-static/resources/view/tailscale/setting.js
assert_contains "display:flex;align-items:center;gap:10px;min-width:0" htdocs/luci-static/resources/view/tailscale/setting.js
assert_contains "display:flex;align-items:center;justify-content:center;line-height:0;flex:0 0 24px" htdocs/luci-static/resources/view/tailscale/setting.js
assert_contains "max-width:260px" htdocs/luci-static/resources/view/tailscale/setting.js
assert_not_contains "max-width:820px" htdocs/luci-static/resources/view/tailscale/setting.js
assert_not_contains "grid-template-columns:24px minmax(160px,1fr) auto" htdocs/luci-static/resources/view/tailscale/setting.js
assert_not_contains "grid-template-columns:24px minmax(180px,1fr) minmax(140px,260px)" htdocs/luci-static/resources/view/tailscale/setting.js
assert_not_contains "padding:10px 12px" htdocs/luci-static/resources/view/tailscale/setting.js
assert_not_contains "grid-template-columns:24px 1fr" htdocs/luci-static/resources/view/tailscale/setting.js
assert_not_contains "form.AbstractValue, 'keepalive_peers'" htdocs/luci-static/resources/view/tailscale/setting.js
assert_not_contains "form.MultiValue, 'keepalive_peers'" htdocs/luci-static/resources/view/tailscale/setting.js
assert_file htdocs/luci-static/resources/view/tailscale/peers.js
assert_file tests/peers_pagination_test.js
assert_contains "Tailscale Peers" htdocs/luci-static/resources/view/tailscale/peers.js
assert_contains "filterMode" htdocs/luci-static/resources/view/tailscale/peers.js
assert_contains "buildPeerGroups" htdocs/luci-static/resources/view/tailscale/peers.js
assert_contains "paginatePeerGroups" htdocs/luci-static/resources/view/tailscale/peers.js
assert_contains "PEER_PAGE_SIZE_DEFAULT = 25" htdocs/luci-static/resources/view/tailscale/peers.js
assert_contains "PEER_PAGE_SIZE_OPTIONS" htdocs/luci-static/resources/view/tailscale/peers.js
assert_peer_pagination_after_table htdocs/luci-static/resources/view/tailscale/peers.js
assert_contains "peer-pagination-summary" htdocs/luci-static/resources/view/tailscale/peers.js
assert_contains "peer-pagination-controls" htdocs/luci-static/resources/view/tailscale/peers.js
assert_contains "justify-content:flex-end" htdocs/luci-static/resources/view/tailscale/peers.js
assert_contains "margin-left:auto" htdocs/luci-static/resources/view/tailscale/peers.js
assert_contains "split oversized groups into dedicated pages" htdocs/luci-static/resources/view/tailscale/peers.js
assert_contains "pageSize === 0" htdocs/luci-static/resources/view/tailscale/peers.js
assert_contains "pageIndex = 0" htdocs/luci-static/resources/view/tailscale/peers.js
assert_contains "peer.userKey" htdocs/luci-static/resources/view/tailscale/peers.js
assert_contains "peer-group-header" htdocs/luci-static/resources/view/tailscale/peers.js
assert_contains "peer-group-title" htdocs/luci-static/resources/view/tailscale/peers.js
assert_contains "border-left:4px solid #64748b" htdocs/luci-static/resources/view/tailscale/peers.js
assert_contains "font-size:16px" htdocs/luci-static/resources/view/tailscale/peers.js
assert_contains "peer.online ? '' : 'opacity:0.62'" htdocs/luci-static/resources/view/tailscale/peers.js
assert_contains "Offline peers cannot be probed" htdocs/luci-static/resources/view/tailscale/peers.js
assert_contains "type: 'button'" htdocs/luci-static/resources/view/tailscale/peers.js
assert_contains "disabled: probing || !peer.online ? 'disabled' : null" htdocs/luci-static/resources/view/tailscale/peers.js
assert_contains "preventDefault" htdocs/luci-static/resources/view/tailscale/peers.js
assert_contains "stopPropagation" htdocs/luci-static/resources/view/tailscale/peers.js
assert_contains "window.scrollTo" htdocs/luci-static/resources/view/tailscale/peers.js
assert_contains "querySelector('.main-right')" htdocs/luci-static/resources/view/tailscale/peers.js
assert_contains "restoreScrollState" htdocs/luci-static/resources/view/tailscale/peers.js
assert_contains "scrollElement.scrollTop" htdocs/luci-static/resources/view/tailscale/peers.js
assert_contains "renderRows(true)" htdocs/luci-static/resources/view/tailscale/peers.js
assert_not_contains "disabled: probing," htdocs/luci-static/resources/view/tailscale/peers.js
assert_contains "PROBE_MAX_ATTEMPTS = 5" htdocs/luci-static/resources/view/tailscale/peers.js
assert_contains "PROBE_RETRY_DELAY_MS" htdocs/luci-static/resources/view/tailscale/peers.js
assert_contains "Continuing probe %d/%d" htdocs/luci-static/resources/view/tailscale/peers.js
assert_contains "%d probes; direct connection not established" htdocs/luci-static/resources/view/tailscale/peers.js
assert_contains 'msgid "Continuing probe %d/%d"' po/zh_Hans/tailscale.po
assert_contains 'msgstr "继续探测 %d/%d"' po/zh_Hans/tailscale.po
assert_contains 'msgid "%d probes; direct connection not established"' po/zh_Hans/tailscale.po
assert_contains 'msgstr "%d 次探测后仍未建立直连"' po/zh_Hans/tailscale.po
assert_contains 'msgid "Continuing probe %d/%d"' po/zh_Hant/tailscale.po
assert_contains 'msgstr "繼續探測 %d/%d"' po/zh_Hant/tailscale.po
assert_contains 'msgid "%d probes; direct connection not established"' po/zh_Hant/tailscale.po
assert_contains 'msgstr "%d 次探測後仍未建立直連"' po/zh_Hant/tailscale.po
assert_contains 'msgid "Items per page"' po/zh_Hans/tailscale.po
assert_contains 'msgstr "每页数量"' po/zh_Hans/tailscale.po
assert_contains 'msgid "All peers"' po/zh_Hans/tailscale.po
assert_contains 'msgstr "全部对端"' po/zh_Hans/tailscale.po
assert_contains 'msgid "Showing %d-%d of %d peers"' po/zh_Hans/tailscale.po
assert_contains 'msgstr "显示第 %d-%d 台,共 %d 台"' po/zh_Hans/tailscale.po
assert_contains 'msgid "Page %d / %d"' po/zh_Hans/tailscale.po
assert_contains 'msgstr "第 %d / %d 页"' po/zh_Hans/tailscale.po
assert_contains 'msgid "Previous"' po/zh_Hans/tailscale.po
assert_contains 'msgstr "上一页"' po/zh_Hans/tailscale.po
assert_contains 'msgid "Next"' po/zh_Hans/tailscale.po
assert_contains 'msgstr "下一页"' po/zh_Hans/tailscale.po
assert_contains 'msgid "Offline peers cannot be probed"' po/templates/tailscale.pot
assert_contains 'msgid "Offline peers cannot be probed"' po/zh_Hans/tailscale.po
assert_contains 'msgstr "离线设备不可探测"' po/zh_Hans/tailscale.po
assert_contains 'msgid "Offline peers cannot be probed"' po/zh_Hant/tailscale.po
assert_contains 'msgstr "離線設備不可探測"' po/zh_Hant/tailscale.po
assert_contains 'msgid "Unknown user"' po/templates/tailscale.pot
assert_contains 'msgid "Unknown user"' po/zh_Hans/tailscale.po
assert_contains 'msgstr "未知用户"' po/zh_Hans/tailscale.po
assert_contains 'msgid "Unknown user"' po/zh_Hant/tailscale.po
assert_contains 'msgstr "未知使用者"' po/zh_Hant/tailscale.po
assert_contains "peer.isSelf" htdocs/luci-static/resources/view/tailscale/peers.js
assert_contains "Current device" htdocs/luci-static/resources/view/tailscale/peers.js
assert_po_entry "Current device" "当前设备" po/zh_Hans/tailscale.po
assert_po_entry "Current device" "目前設備" po/zh_Hant/tailscale.po
assert_contains "Advertising subnets" htdocs/luci-static/resources/view/tailscale/peers.js
assert_contains "tailscale_peer_probe" htdocs/luci-static/resources/view/tailscale/peers.js
assert_contains "Probe" htdocs/luci-static/resources/view/tailscale/peers.js
assert_contains "DERP" htdocs/luci-static/resources/view/tailscale/peers.js
assert_contains "Direct" htdocs/luci-static/resources/view/tailscale/peers.js
assert_not_contains "Probe all" htdocs/luci-static/resources/view/tailscale/peers.js
assert_file tests/log_view_test.js
assert_contains "formatLogLines" htdocs/luci-static/resources/view/tailscale/log.js
assert_contains "formatLogError" htdocs/luci-static/resources/view/tailscale/log.js
assert_contains "Log is empty." po/zh_Hans/tailscale.po
assert_contains "日志为空。" po/zh_Hans/tailscale.po
assert_contains "logread command not found" po/zh_Hans/tailscale.po
assert_contains "未找到 logread 命令" po/zh_Hans/tailscale.po
assert_contains 'msgid "Keepalive"' po/zh_Hans/tailscale.po
assert_contains 'msgstr "保活"' po/zh_Hans/tailscale.po
assert_contains 'msgid "Peer Keepalive"' po/zh_Hans/tailscale.po
assert_contains 'msgstr "启用"' po/zh_Hans/tailscale.po
assert_contains 'msgid "Peer Keepalive"' po/zh_Hant/tailscale.po
assert_contains 'msgstr "啟用"' po/zh_Hant/tailscale.po
assert_not_contains 'msgstr "对端保活"' po/zh_Hans/tailscale.po
assert_contains 'msgid "Subnets"' po/zh_Hans/tailscale.po
assert_contains 'msgstr "子网"' po/zh_Hans/tailscale.po
assert_contains 'msgid "Only peers advertising subnet routes are shown. Selected peers are periodically pinged to keep cross-subnet paths active."' po/zh_Hans/tailscale.po
assert_contains 'msgstr "仅显示对端也发布了网段的设备;选中的设备会被定时 Tailscale ping,用于保持跨网段路径活跃。"' po/zh_Hans/tailscale.po
assert_not_contains "子网路由" po/zh_Hans/tailscale.po
assert_not_contains "子網路由" po/zh_Hant/tailscale.po
assert_contains 'msgid "AdGuard DNS"' po/zh_Hans/tailscale.po
assert_contains 'msgstr "AdGuard DNS"' po/zh_Hans/tailscale.po
assert_contains 'msgid "AdGuard Username"' po/zh_Hans/tailscale.po
assert_contains 'msgstr "AdGuard 用户名"' po/zh_Hans/tailscale.po
assert_contains "Expected Internal IPs" htdocs/luci-static/resources/view/tailscale/setting.js
assert_contains 'msgid "Expected Internal IPs"' po/zh_Hans/tailscale.po
assert_contains 'msgstr "预期内网 IP"' po/zh_Hans/tailscale.po
assert_contains 'msgstr "啟用 AdGuard DNS 自動切換前至少需要填寫一個預期內網 IP。"' po/zh_Hant/tailscale.po
assert_not_contains "Expected Health IPs" htdocs/luci-static/resources/view/tailscale/setting.js
assert_not_contains "预期健康 IP" po/zh_Hans/tailscale.po
assert_not_contains "預期健康 IP" po/zh_Hant/tailscale.po
assert_contains "100.100.100.100" root/usr/sbin/tailscale_adguard_dns_switch
assert_contains "100.100.100.100" htdocs/luci-static/resources/view/tailscale/setting.js
assert_not_contains "'subnet_routes'" htdocs/luci-static/resources/view/tailscale/setting.js
assert_not_contains "Subnet Routes" htdocs/luci-static/resources/view/tailscale/setting.js
assert_not_contains "No Available Subnet Routes" htdocs/luci-static/resources/view/tailscale/setting.js
assert_not_contains "config_get subnet_routes" root/etc/init.d/tailscale
assert_not_contains 'SUBNET_ROUTES="$subnet_routes"' root/etc/init.d/tailscale
assert_not_contains "list subnet_routes" root/etc/config/tailscale
assert_not_contains 'msgid "Subnet Routes"' po/templates/tailscale.pot
assert_not_contains 'msgid "No Available Subnet Routes"' po/templates/tailscale.pot
assert_not_contains 'msgid "Subnet Routes"' po/zh_Hans/tailscale.po
assert_not_contains 'msgid "No Available Subnet Routes"' po/zh_Hans/tailscale.po
assert_not_contains 'msgid "Subnet Routes"' po/zh_Hant/tailscale.po
assert_not_contains 'msgid "No Available Subnet Routes"' po/zh_Hant/tailscale.po
assert_not_contains "Tailscale Accept DNS" htdocs/luci-static/resources/view/tailscale/setting.js
assert_not_contains "Accept DNS must be enabled before enabling AdGuard DNS auto switch." htdocs/luci-static/resources/view/tailscale/setting.js
assert_not_contains 'msgid "Tailscale Accept DNS"' po/zh_Hans/tailscale.po
assert_not_contains 'msgid "Accept DNS must be enabled before enabling AdGuard DNS auto switch."' po/zh_Hans/tailscale.po
assert_contains "PROGA=/usr/sbin/tailscale_adguard_dns_switch" root/etc/init.d/tailscale
assert_contains 'tailscale_adguard_dns "$cfg"' root/etc/init.d/tailscale
assert_contains "service_stopped()" root/etc/init.d/tailscale
assert_contains "config_foreach tailscale_adguard_dns_apply_down 'tailscale'" root/etc/init.d/tailscale
assert_contains "--apply-profile down" root/etc/init.d/tailscale
assert_not_contains "adguard_health_dns" htdocs/luci-static/resources/view/tailscale/setting.js
assert_not_contains "Health Check DNS Server" htdocs/luci-static/resources/view/tailscale/setting.js
assert_not_contains "adguard_health_dns" root/etc/config/tailscale
assert_not_contains "adguard_clear_cache" htdocs/luci-static/resources/view/tailscale/setting.js
assert_not_contains "Clear AdGuard Cache After Switch" htdocs/luci-static/resources/view/tailscale/setting.js
assert_not_contains "adguard_clear_cache" root/etc/config/tailscale
assert_not_contains 'msgid "Health Check DNS Server"' po/zh_Hans/tailscale.po
assert_not_contains 'msgid "Clear AdGuard Cache After Switch"' po/zh_Hans/tailscale.po
assert_contains "placeholder = hasAdguardPassword ? _('Configured') : ''" htdocs/luci-static/resources/view/tailscale/setting.js
assert_contains "placeholder = hasAuthKey ? _('Configured') : ''" htdocs/luci-static/resources/view/tailscale/setting.js
assert_contains "Leave blank to keep the existing AdGuard password; enter a new value to replace it." htdocs/luci-static/resources/view/tailscale/setting.js
assert_contains "Used once for first-time enrollment or explicit re-enrollment, then removed after a successful connection. Leave blank for normal restarts." htdocs/luci-static/resources/view/tailscale/setting.js
assert_not_contains "placeholder = hasAdguardPassword ? _('Configured; leave blank to keep existing value.') : ''" htdocs/luci-static/resources/view/tailscale/setting.js
assert_not_contains "placeholder = hasAuthKey ? _('Configured; leave blank to keep existing value.') : ''" htdocs/luci-static/resources/view/tailscale/setting.js
assert_contains 'msgid "Configured"' po/zh_Hans/tailscale.po
assert_contains 'msgstr "已配置"' po/zh_Hans/tailscale.po
assert_contains 'msgid "Leave blank to keep the existing AdGuard password; enter a new value to replace it."' po/zh_Hans/tailscale.po
assert_contains 'msgstr "留空则保留现有 AdGuard 密码;填写新值则覆盖。"' po/zh_Hans/tailscale.po
assert_contains 'msgid "Used once for first-time enrollment or explicit re-enrollment, then removed after a successful connection. Leave blank for normal restarts."' po/zh_Hans/tailscale.po
assert_contains 'msgstr "仅用于首次注册或明确重新注册,连接成功后自动删除;正常重启请留空。"' po/zh_Hans/tailscale.po
assert_contains 'msgid "Failed to stage protected credentials."' po/zh_Hans/tailscale.po
assert_contains 'msgstr "暂存受保护凭证失败。"' po/zh_Hans/tailscale.po
assert_contains '"luci.tailscale": [ "adguard_preflight", "set_secrets" ]' root/usr/share/rpcd/acl.d/luci-app-tailscale.json
acl_file=root/usr/share/rpcd/acl.d/luci-app-tailscale.json
assert_jq '.["luci-app-tailscale"].read.uci == ["tailscale", "tailscale_openclash", "tailscale_policy_routing"]' "$acl_file" 'ACL read UCI access must include the isolated policy-routing package'
assert_jq '.["luci-app-tailscale"].write.uci == ["tailscale", "tailscale_openclash", "tailscale_policy_routing"]' "$acl_file" 'ACL write UCI access must include the isolated policy-routing package'
assert_jq '.["luci-app-tailscale"].read.ubus["luci.tailscale"] == ["secret_status", "openclash_bypass_status", "policy_routing_status"]' "$acl_file" 'ACL read ubus access must include the policy-routing status method'
assert_jq '.["luci-app-tailscale"].read.file | has("/usr/sbin/tailscale_openclash_bypass") | not' "$acl_file" 'ACL must not grant file-exec access to the OpenClash helper'
assert_not_contains '"set_secret"' root/usr/share/rpcd/acl.d/luci-app-tailscale.json
assert_not_contains '"luci.tailscale": [ "adguard_preflight" ]' root/usr/share/rpcd/acl.d/luci-app-tailscale.json
assert_not_contains "/usr/sbin/tailscale_adguard_dns_switch --preflight" root/usr/share/rpcd/acl.d/luci-app-tailscale.json
assert_contains '"/usr/sbin/tailscale_peer_probe": [ "exec" ]' root/usr/share/rpcd/acl.d/luci-app-tailscale.json
assert_not_contains "config_get authkey" root/etc/init.d/tailscale
assert_not_contains "get authkey" root/etc/init.d/tailscale
assert_not_contains "--authkey=" root/etc/init.d/tailscale
assert_contains 'procd_set_param command $PROG --run' root/etc/init.d/tailscale
assert_contains 'TAILSCALE_SECRETS_BIN' root/usr/sbin/tailscale_helper
assert_not_contains 'TAILSCALE_INIT' root/usr/sbin/tailscale_helper
assert_contains 'Unsupported tailscale_helper mode' root/usr/sbin/tailscale_helper
assert_contains '"$TAILSCALE_SECRETS_BIN" migrate' root/etc/uci-defaults/40_luci-tailscale
assert_contains 'cleanup_legacy_adguard_dns_service' root/etc/uci-defaults/40_luci-tailscale
assert_contains 'tailscale-adguard-dns.disabled' root/etc/uci-defaults/40_luci-tailscale
assert_before 'cleanup_legacy_adguard_dns_service' '"$TAILSCALE_SECRETS_BIN" migrate' root/etc/uci-defaults/40_luci-tailscale
assert_contains 'disable_legacy_hotplug' Makefile
assert_contains '40-tailscale.disabled' Makefile
assert_contains 'rm -f "$${legacy_hotplug}" || chmod 000 "$${legacy_hotplug}" || return 1' Makefile
assert_before 'disable_legacy_hotplug || exit 1' '[ -f /etc/config/tailscale ] || exit 0' Makefile
assert_not_contains 'ucitrack.@tailscale' root/etc/uci-defaults/40_luci-tailscale
assert_not_contains 'uci -q batch' root/etc/uci-defaults/40_luci-tailscale
assert_contains 'openclash_bypass_enabled="$("$UCI_BIN" -q get tailscale_openclash.settings.enabled' root/etc/uci-defaults/40_luci-tailscale
assert_contains '"$OPENCLASH_BYPASS_INIT" enable' root/etc/uci-defaults/40_luci-tailscale
assert_contains '"$OPENCLASH_BYPASS_INIT" start' root/etc/uci-defaults/40_luci-tailscale
assert_before '"$TAILSCALE_SECRETS_BIN" migrate' '"$OPENCLASH_BYPASS_INIT" enable' root/etc/uci-defaults/40_luci-tailscale
assert_before '"$OPENCLASH_BYPASS_INIT" enable' '"$OPENCLASH_BYPASS_INIT" start' root/etc/uci-defaults/40_luci-tailscale
assert_contains 'POLICY_ROUTING_INIT=' root/etc/uci-defaults/40_luci-tailscale
assert_contains '"$POLICY_ROUTING_INIT" enable' root/etc/uci-defaults/40_luci-tailscale
assert_contains '"$POLICY_ROUTING_INIT" start' root/etc/uci-defaults/40_luci-tailscale
assert_not_contains "option adguard_password" root/etc/config/tailscale
assert_not_contains "option authkey" root/etc/config/tailscale
assert_contains "--cleanup-managed-firewall" root/etc/init.d/tailscale
assert_contains "TAILSCALE_INTERNAL_RELOAD" root/etc/init.d/tailscale
assert_not_contains "RELOAD_MARKER_FILE" root/etc/init.d/tailscale
assert_contains 'activate "$secrets_ref"' root/etc/init.d/tailscale
assert_contains "Package/luci-app-tailscale/prerm" Makefile
assert_contains "Package/luci-app-tailscale/preinst" Makefile
assert_not_contains "Package/luci-app-tailscale/conffiles" Makefile
assert_contains "/etc/config/tailscale" Makefile
assert_contains "/etc/config/tailscale_openclash" Makefile
assert_contains "/etc/config/tailscale_policy_routing" Makefile
assert_not_contains "Package/luci-app-tailscale/postinst" Makefile
assert_contains "state_dir=/etc/.luci-app-tailscale-upgrade" Makefile
assert_contains 'state_pending="$${state_dir}.pending"' Makefile
assert_contains 'clear_state_dir()' Makefile
assert_contains "cp /etc/config/tailscale \"\$\${state_pending}/tailscale\"" Makefile
assert_not_contains "/tmp/luci-app-tailscale-upgrade" Makefile
assert_contains "restore_upgrade_config" root/etc/uci-defaults/40_luci-tailscale
assert_contains "cleanup_upgrade_state()" root/etc/uci-defaults/40_luci-tailscale
assert_contains 'UPGRADE_STATE_DIR="${UPGRADE_STATE_DIR:-/etc/.luci-app-tailscale-upgrade}"' root/etc/uci-defaults/40_luci-tailscale
assert_contains "chmod 700 \"\$\${state_pending}\"" Makefile
assert_before "restore_upgrade_config" '"$TAILSCALE_SECRETS_BIN" migrate' root/etc/uci-defaults/40_luci-tailscale
assert_not_contains "/etc/init.d/tailscale-openclash-bypass enable" Makefile
assert_not_contains "/etc/init.d/tailscale-openclash-bypass start" Makefile
assert_contains '/etc/init.d/tailscale-openclash-bypass disable >/dev/null 2>&1 || true' Makefile
assert_contains '[ -z "$${IPKG_INSTROOT}" ] && [ -x /usr/sbin/tailscale_openclash_bypass ]; then' Makefile
assert_contains "/usr/sbin/tailscale_openclash_bypass cleanup >/dev/null 2>&1 || true" Makefile
assert_contains "/usr/sbin/tailscale_policy_routing cleanup >/dev/null 2>&1 || exit 1" Makefile
assert_contains 'case "$${2:-}" in' Makefile
assert_contains 'remove|[0-9]*) ;;' Makefile
assert_contains 'case "$${1:-}" in' Makefile
assert_contains '"$${cleanup_dir}/tailscale_openclash.absent" "$${cleanup_dir}/tailscale_policy_routing"' Makefile
assert_contains '"$${cleanup_dir}/.complete"' Makefile
assert_not_contains 'APK_SCRIPT' Makefile
assert_before "/usr/sbin/tailscale_openclash_bypass cleanup" "/etc/init.d/tailscale stop" Makefile
assert_before "/etc/init.d/tailscale-openclash-bypass disable" "/etc/init.d/tailscale stop" Makefile
assert_before "/usr/sbin/tailscale_policy_routing cleanup" "/etc/init.d/tailscale stop" Makefile
assert_before "/etc/init.d/tailscale-policy-routing disable" "/etc/init.d/tailscale stop" Makefile
assert_contains "/etc/init.d/tailscale stop" Makefile
assert_not_contains "/etc/init.d/tailscale stop >/dev/null 2>&1 || true" Makefile
assert_not_contains "grep -b" root/usr/sbin/tailscale_openclash_bypass
assert_contains '"$PROG" sync' root/etc/init.d/tailscale-openclash-bypass
assert_not_contains "config_get_bool" root/etc/init.d/tailscale-openclash-bypass
assert_contains 'Status states: `active`, `waiting`, `disabled`, `absent`, `unsupported`, and `error`.' README.md
assert_contains 'Cleanup removes only the managed hook block and the four `luci-app-tailscale:` rules.' README.md
assert_not_exists root/lib/netifd/proto/tailscale.sh
assert_not_exists htdocs/luci-static/resources/protocol/tailscale.js
assert_not_exists root/etc/init.d/tailscale-adguard-dns
assert_not_exists root/etc/hotplug.d/iface/40-tailscale
echo "package release tests passed"
reload_service() {
restart
}
@@ -1,286 +0,0 @@
const fs = require('fs');
const vm = require('vm');
const source = fs.readFileSync('htdocs/luci-static/resources/view/tailscale/peers.js', 'utf8');
const helperSource = source.split('return view.extend({')[0];
const context = {
module: { exports: {} },
console,
_: value => value
};
vm.runInNewContext(`${helperSource}\nmodule.exports = { parseStatus, buildPeerGroups, paginatePeerGroups, parseProbeResult, appendProbeSummary };`, context);
const { parseStatus, buildPeerGroups, paginatePeerGroups, parseProbeResult, appendProbeSummary } = context.module.exports;
function peer(id) {
return { id: String(id) };
}
function group(name, count) {
return {
key: name,
name,
loginName: '',
peers: Array.from({ length: count }, (_, index) => peer(`${name}-${index}`))
};
}
function assert(condition, message) {
if (!condition)
throw new Error(message);
}
function groupNames(page) {
return page.groups.map(item => item.name).join(',');
}
const mixedGroups = [group('user-a', 20), group('user-b', 5), group('user-c', 10)];
let page = paginatePeerGroups(mixedGroups, 25, 0);
assert(page.pageCount === 2, `expected two pages, got ${page.pageCount}`);
assert(groupNames(page) === 'user-a,user-b', `unexpected first page groups: ${groupNames(page)}`);
assert(page.start === 1 && page.end === 25 && page.total === 35, 'unexpected first page range');
page = paginatePeerGroups(mixedGroups, 25, 1);
assert(groupNames(page) === 'user-c', `unexpected second page groups: ${groupNames(page)}`);
assert(page.start === 26 && page.end === 35, 'unexpected second page range');
const oversizedGroups = [group('large-user', 40), group('small-user', 5)];
page = paginatePeerGroups(oversizedGroups, 25, 0);
assert(groupNames(page) === 'large-user', `unexpected oversized first page groups: ${groupNames(page)}`);
assert(page.groups[0].peers.length === 25, `expected first chunk of 25, got ${page.groups[0].peers.length}`);
page = paginatePeerGroups(oversizedGroups, 25, 1);
assert(groupNames(page) === 'large-user', `unexpected oversized second page groups: ${groupNames(page)}`);
assert(page.groups[0].peers.length === 15, `expected second chunk of 15, got ${page.groups[0].peers.length}`);
page = paginatePeerGroups(oversizedGroups, 25, 2);
assert(groupNames(page) === 'small-user', `unexpected oversized third page groups: ${groupNames(page)}`);
assert(page.groups[0].peers.length === 5, `expected small group page of 5, got ${page.groups[0].peers.length}`);
page = paginatePeerGroups(oversizedGroups, 0, 0);
assert(page.pageCount === 1, `expected all mode single page, got ${page.pageCount}`);
assert(groupNames(page) === 'large-user,small-user', `unexpected all mode groups: ${groupNames(page)}`);
const normalizedPeers = parseStatus(JSON.stringify({
User: {
'1': { DisplayName: 'Zeta User', LoginName: 'zeta@example.test' },
'2': { DisplayName: 'Alpha User', LoginName: 'alpha@example.test' }
},
Self: {
ID: 'self-node',
HostName: 'hz-office-openwrt',
DNSName: 'hz-office-openwrt.example.ts.net.',
TailscaleIPs: ['100.64.2.15'],
PrimaryRoutes: ['192.168.100.0/24'],
UserID: 2,
Online: true
},
Peer: {
'node:gateway': {
HostName: 'gateway',
DNSName: 'gateway.example.ts.net.',
TailscaleIPs: ['100.64.0.20'],
PrimaryRoutes: ['10.20.0.0/24', '', null],
UserID: 2,
Online: 1
},
'node:alpha': {
HostName: 'legacy-host',
DNSName: 'alpha.example.ts.net.',
TailscaleIPs: ['100.64.0.10'],
PrimaryRoutes: [],
UserID: '1',
Online: false
},
'node:unknown': {
HostName: '',
DNSName: '',
TailscaleIPs: ['100.64.0.30'],
PrimaryRoutes: null,
Online: true
}
}
}));
assert(normalizedPeers.map(item => item.id).join(',') === 'node:unknown,node:alpha,node:gateway,self-node', 'status peers should normalize names and sort deterministically');
assert(normalizedPeers.find(item => item.id === 'node:alpha').name === 'alpha (legacy-host)', 'status normalization should combine distinct short DNS and host names');
assert(normalizedPeers.find(item => item.id === 'node:gateway').probeTarget === '100.64.0.20', 'status normalization should prefer the Tailscale IP as probe target');
assert(normalizedPeers.find(item => item.id === 'node:gateway').routes.join(',') === '10.20.0.0/24' && normalizedPeers.find(item => item.id === 'node:gateway').hasSubnetRoutes, 'status normalization should remove empty routes and flag advertised subnets');
assert(normalizedPeers.find(item => item.id === 'node:unknown').userKey === 'unknown', 'missing user IDs should normalize into the unknown group');
const selfPeer = normalizedPeers.find(item => item.isSelf);
assert(selfPeer && selfPeer.name === 'hz-office-openwrt', 'Self should be normalized into the device list');
assert(selfPeer.userName === 'Alpha User', 'Self should resolve its owner through UserID');
assert(selfPeer.routes.join(',') === '192.168.100.0/24', 'Self should preserve its advertised routes');
const normalizedGroups = buildPeerGroups(normalizedPeers);
assert(normalizedGroups.map(item => item.name).join(',') === 'Alpha User,Unknown user,Zeta User', 'peer groups should sort by normalized user display name');
assert(normalizedGroups[0].peers[0].id === 'node:gateway', 'peer grouping should retain the normalized user association');
const alphaGroup = normalizedGroups.find(item => item.key === '2');
assert(alphaGroup && alphaGroup.peers.length === 2, 'Self and regular peers with the same UserID should share one user group');
assert(alphaGroup.peers.some(item => item.isSelf) && alphaGroup.peers.some(item => item.id === 'node:gateway'), 'shared user group should contain both Self and the regular peer');
const normalizedPage = paginatePeerGroups(normalizedGroups, 1, 1);
assert(normalizedPage.total === normalizedPeers.length, 'Self should be included in the paginated peer count');
assert(normalizedPage.groups.length === 1 && normalizedPage.groups[0].peers[0].isSelf, 'Self should participate in pagination as a peer entry');
const peerOnlyPeers = parseStatus(JSON.stringify({
User: {
'3': { DisplayName: 'Peer Only User' }
},
Peer: {
'node:peer-only': {
HostName: 'peer-only-host',
TailscaleIPs: ['100.64.0.50'],
UserID: 3,
Online: true
}
}
}));
assert(peerOnlyPeers.length === 1 && peerOnlyPeers[0].id === 'node:peer-only', 'status without Self should preserve the peer-only list');
assert(!peerOnlyPeers[0].isSelf && peerOnlyPeers[0].userName === 'Peer Only User', 'peer-only status should remain compatible with existing user normalization');
const largeUserId = '9007199254740993';
const largeIdPeers = parseStatus(`{"User":{"${largeUserId}":{"DisplayName":"Large ID User"}},"Peer":{"node:large":{"HostName":"large-id-peer","TailscaleIPs":["100.64.0.99"],"UserID":${largeUserId},"Online":true}}}`);
assert(largeIdPeers[0].userKey === largeUserId, 'status parsing must preserve UserID values larger than JavaScript safe integers');
assert(largeIdPeers[0].userName === 'Large ID User', 'large UserID values must still resolve the correct user metadata');
const derpProbe = parseProbeResult('{"ok":true,"path":"derp","relay":"test-relay","summary":"DERP test-relay 24 ms"}', 'ignored');
assert(derpProbe.path === 'derp' && derpProbe.relay === 'test-relay', 'probe parser should preserve a valid DERP result');
const progressiveProbe = appendProbeSummary(derpProbe, 'Continuing probe 1/5');
assert(progressiveProbe.summary === 'DERP test-relay 24 ms - Continuing probe 1/5', 'progressive DERP parsing should append the retry status');
assert(derpProbe.summary === 'DERP test-relay 24 ms', 'progressive probe summary should not mutate the parsed result');
const failedProbe = parseProbeResult('{invalid', 'probe output was incomplete');
assert(failedProbe.path === 'failed' && failedProbe.summary === 'probe output was incomplete', 'invalid progressive probe output should use the supplied failure detail');
function createElement(tag, attrs, children) {
if (arguments.length === 2 && (typeof attrs !== 'object' || Array.isArray(attrs))) {
children = attrs;
attrs = {};
}
return {
tag,
attrs: attrs || {},
children: children == null ? [] : (Array.isArray(children) ? children : [children])
};
}
function findRowWithText(node, text) {
if (!node || typeof node !== 'object')
return null;
if (node.tag === 'tr' && JSON.stringify(node).includes(text))
return node;
for (const child of (node.children || []).concat(node.content || [])) {
const row = findRowWithText(child, text);
if (row)
return row;
}
return null;
}
async function testRefreshFailureClearsStaleRows() {
let pollCallback;
const viewContext = {
console,
_: value => value,
E: createElement,
dom: {
content: (node, content) => {
node.content = content;
}
},
fs: {
exec: async () => ({ code: 1, stderr: 'status refresh unavailable' })
},
poll: {
add: callback => {
pollCallback = callback;
}
},
view: {
extend: value => value
}
};
const viewObject = vm.runInNewContext(`
String.prototype.format = function() {
var args = arguments;
var index = 0;
return String(this).replace(/%[sd]/g, function() { return String(args[index++]); });
};
(function() { ${source}\n })();
`, viewContext);
const stalePeer = {
id: 'stale-peer-id',
name: 'stale-peer-name',
ip: '100.64.0.40',
userKey: '1',
userName: 'Example User',
userLoginName: 'user@example.test',
online: true,
lastSeen: '-',
exitNode: false,
routes: [],
hasSubnetRoutes: false,
probeTarget: '100.64.0.40'
};
const rendered = viewObject.render({ ok: true, peers: [stalePeer], error: '' });
assert(JSON.stringify(rendered).includes('stale-peer-name'), 'refresh test should begin with a rendered stale peer');
assert(typeof pollCallback === 'function', 'peer view should register its refresh callback');
await pollCallback();
const refreshed = JSON.stringify(rendered);
assert(!refreshed.includes('stale-peer-name'), 'failed refresh must clear stale peer rows');
assert(refreshed.includes('status refresh unavailable'), 'failed refresh should render the current status error');
assert(refreshed.includes('No peers found'), 'failed refresh should render the empty peer state');
}
async function testCurrentDeviceRendersWithoutProbeAction() {
const viewContext = {
console,
_: value => value,
E: createElement,
dom: { content: (node, content) => { node.content = content; } },
fs: { exec: async () => ({ code: 0, stdout: '{}' }) },
poll: { add: () => {} },
view: { extend: value => value }
};
const viewObject = vm.runInNewContext(`
String.prototype.format = function() {
var args = arguments;
var index = 0;
return String(this).replace(/%[sd]/g, function() { return String(args[index++]); });
};
(function() { ${source}\n })();
`, viewContext);
const currentDevice = {
id: 'self-node',
name: 'hz-office-openwrt',
ip: '100.64.2.15',
userKey: '2',
userName: 'Alpha User',
userLoginName: 'alpha@example.test',
online: true,
lastSeen: '-',
exitNode: false,
routes: ['192.168.100.0/24'],
hasSubnetRoutes: true,
probeTarget: '100.64.2.15',
isSelf: true
};
const rendered = viewObject.render({ ok: true, peers: [currentDevice], error: '' });
const serialized = JSON.stringify(rendered);
const currentDeviceRow = findRowWithText(rendered, currentDevice.name);
assert(serialized.includes('Current device'), 'current device row should show a current-device probe cell');
assert(currentDeviceRow && !JSON.stringify(currentDeviceRow).includes('Probe'), 'current device row should not render a Probe action');
}
Promise.all([testRefreshFailureClearsStaleRows(), testCurrentDeviceRendersWithoutProbeAction()]).then(() => {
console.log('peers pagination and behavior tests passed');
}).catch(error => {
console.error(error);
process.exit(1);
});
@@ -1,80 +0,0 @@
#!/bin/sh
set -eu
workflow=.github/workflows/release.yml
readme=README.md
sdk_runner=.github/scripts/build-openwrt-package.sh
sdk_build=.github/scripts/openwrt-sdk-build.sh
build_job="$(sed -n '/^ build:/,/^ release:/p' "$workflow")"
release_body="$(sed -n '/^ body: |/,$p' "$workflow")"
printf '%s\n' "$build_job" | grep -F 'path: luci-app-tailscale' >/dev/null
printf '%s\n' "$build_job" | grep -F 'if [[ -d logs ]]; then' >/dev/null
grep -F 'name: Verify release tag matches package version' "$workflow" >/dev/null
grep -F 'GITHUB_REF_NAME' "$workflow" >/dev/null
grep -F 'PKG_VERSION:=' "$workflow" >/dev/null
grep -F "luci-i18n-tailscale-zh-cn_*.ipk" "$workflow" >/dev/null
grep -F "luci-i18n-tailscale-zh-tw_*.ipk" "$workflow" >/dev/null
grep -F "luci-i18n-tailscale-zh-cn-*.apk" "$workflow" >/dev/null
grep -F "luci-i18n-tailscale-zh-tw-*.apk" "$workflow" >/dev/null
grep -F 'expected exactly one artifact' "$workflow" >/dev/null
grep -E 'softprops/action-gh-release@[0-9a-f]{40}' "$workflow" >/dev/null
grep -F 'generate_release_notes: true' "$workflow" >/dev/null
if grep -F 'softprops/action-gh-release@v2' "$workflow" >/dev/null; then
printf '%s\n' 'release action must be pinned to an immutable commit' >&2
exit 1
fi
check_install_text() {
label="$1"
text="$2"
if printf '%s\n' "$text" | grep -F -- '--force-overwrite' >/dev/null; then
printf '%s\n' "$label must not recommend force-overwrite for normal installation" >&2
exit 1
fi
if printf '%s\n' "$text" | grep -F '/tmp/luci-*-tailscale*' >/dev/null; then
printf '%s\n' "$label must not install every translation package through a broad wildcard" >&2
exit 1
fi
printf '%s\n' "$text" | grep -F '/tmp/luci-app-tailscale_' >/dev/null
printf '%s\n' "$text" | grep -F '/tmp/luci-app-tailscale-' >/dev/null
printf '%s\n' "$text" | grep -F '/tmp/luci-i18n-tailscale-zh-cn_' >/dev/null
printf '%s\n' "$text" | grep -F '/tmp/luci-i18n-tailscale-zh-cn-' >/dev/null
printf '%s\n' "$text" | grep -F '/tmp/luci-i18n-tailscale-zh-tw_' >/dev/null
printf '%s\n' "$text" | grep -F '/tmp/luci-i18n-tailscale-zh-tw-' >/dev/null
}
check_install_text "$readme" "$(cat "$readme")"
check_install_text "$workflow release body" "$release_body"
grep -F 'apk add --allow-untrusted' "$readme" >/dev/null
printf '%s\n' "$release_body" | grep -F 'apk add --allow-untrusted' >/dev/null
grep -F 'V: s' "$workflow" >/dev/null
grep -F 'name: Write build failure summary' "$workflow" >/dev/null
grep -F '>> "$GITHUB_STEP_SUMMARY"' "$workflow" >/dev/null
grep -F 'name: Upload build diagnostics' "$workflow" >/dev/null
grep -F 'if: failure()' "$workflow" >/dev/null
grep -F 'logs/**' "$workflow" >/dev/null
grep -E 'sdk_arch: x86_64-24\.10\.5@sha256:[0-9a-f]{64}' "$workflow" >/dev/null
grep -E 'sdk_arch: x86_64@sha256:[0-9a-f]{64}' "$workflow" >/dev/null
if grep -Eq 'sdk_arch: x86_64(-24\.10\.5)?$' "$workflow"; then
printf '%s\n' 'release SDK images must be pinned by digest' >&2
exit 1
fi
grep -F 'bash luci-app-tailscale/.github/scripts/build-openwrt-package.sh' "$workflow" >/dev/null
grep -F 'docker pull "$SDK_IMAGE"' "$sdk_runner" >/dev/null
grep -F 'docker run --rm' "$sdk_runner" >/dev/null
grep -F -- '--volume "$ARTIFACTS_DIR:/feed"' "$sdk_runner" >/dev/null
if grep -F -- '--volume "$PACKAGE_FEED_DIR:/feed"' "$sdk_runner" >/dev/null; then
printf '%s\n' 'the OpenWrt feed mount must contain the package directory, not point at the package itself' >&2
exit 1
fi
grep -F './scripts/feeds update -a' "$sdk_build" >/dev/null
grep -F 'package/$PACKAGE_NAME/compile' "$sdk_build" >/dev/null
if grep -F 'gh-action-sdk@' "$workflow" >/dev/null; then
printf '%s\n' 'the release build must not depend on a composite SDK action with mutable nested actions' >&2
exit 1
fi
printf '%s\n' 'release workflow diagnostics test passed'
@@ -1,339 +0,0 @@
const fs = require('fs');
const vm = require('vm');
const source = fs.readFileSync('htdocs/luci-static/resources/view/tailscale/setting.js', 'utf8');
function assert(value, message) {
if (!value)
throw new Error(message);
}
assert(source.includes("uci.load('tailscale_openclash')"), 'setting view must load the isolated OpenClash UCI package');
assert(source.includes("method: 'openclash_bypass_status'"), 'setting view must use the read-only status RPC');
assert(source.includes("uci.set('tailscale_openclash', 'settings', 'enabled'"), 'toggle must write only the isolated UCI package');
assert(!source.includes("uci.set('tailscale', section_id, 'openclash"), 'toggle must not write the core Tailscale UCI package');
assert(source.includes("_('Protect Tailscale Traffic (Bypass OpenClash)')"), 'toggle must explain that it protects Tailscale traffic');
assert(source.includes("_('Keep Tailscale control connections, direct connections, Tailnet DNS, and subnet traffic outside OpenClash. When disabled, this traffic is handled by OpenClash; node connectivity, direct paths, subnet access, and internal DNS are no longer protected by this feature. Keep this enabled while using OpenClash.')"), 'description must name the protected traffic and disabled impact');
assert(source.includes("_('Enabled; 4 bypass rules are active')"), 'active status must report the concrete rule state');
assert(source.includes("_('Disabled; Tailscale traffic is handled by OpenClash')"), 'disabled status must state the traffic impact');
assert(!source.includes("_('Enable OpenClash Bypass')"), 'old implementation-only toggle copy must be removed');
assert(!source.includes("_('Bypass OpenClash for Tailscale marked host traffic and traffic entering from tailscale0. This feature does not reload firewall4 or manage the OpenClash service.')"), 'old implementation-only description must be removed');
const loadMatch = source.match(/load\(\)\s*\{([\s\S]*?)\n\t\},\n\n\trender/);
assert(loadMatch, 'expected setting view to define a load() method');
assert(!loadMatch[1].includes('callOpenclashBypassStatus('), 'OpenClash status must not block initial page rendering');
assert(source.includes('refreshOpenclashBypassStatus'), 'status must refresh after render');
function createRuntime(openclashState = 'active') {
const rpcDeclarations = [];
const rpcCalls = [];
const uciLoads = [];
const uciSets = [];
const scheduled = [];
const maps = [];
const lifecycle = [];
const nodes = new Map();
const statusNodes = [];
const openclashUci = { enabled: '1' };
const luciString = value => String(value);
luciString.format = (format, ...values) => format.replace(/%s/g, () => values.shift());
function textFrom(value) {
if (Array.isArray(value))
return value.map(textFrom).join('');
if (value == null)
return '';
if (typeof value === 'object')
return textFrom(value.children);
return String(value);
}
function E(tag, attrs, children) {
return {
tag,
attrs: attrs || {},
children,
textContent: textFrom(children)
};
}
function registerNode(value) {
if (Array.isArray(value)) {
value.forEach(registerNode);
return;
}
if (!value || typeof value !== 'object')
return;
const id = value.attrs && value.attrs.id;
if (id) {
nodes.set(id, value);
if (id === 'openclash_bypass_status')
statusNodes.push(value);
}
registerNode(value.children);
}
function Option(option) {
this.option = option;
}
Option.prototype.depends = function() {};
Option.prototype.value = function() {};
Option.prototype.formvalue = function() {
return this.map && this.map.formValues[this.option] || '';
};
Option.prototype.cbid = function(sectionId) { return sectionId + '.' + this.option; };
function Section(map) {
this.map = map;
this.tabs = [];
this.options = [];
}
Section.prototype.tab = function(tab) {
this.tabs.push(tab);
};
Section.prototype.taboption = function(tab, Type, option, title, description) {
const value = new Type(option);
value.map = this.map;
value.tab = tab;
value.title = title;
value.description = description;
this.options.push(value);
return value;
};
Section.prototype.option = function(Type, option) {
const value = new Type(option);
value.map = this.map;
this.options.push(value);
return value;
};
function FormMap(config) {
this.config = config;
this.sections = [];
this.formValues = {};
this.data = {
save: async () => lifecycle.push('uci-save'),
load: async () => lifecycle.push('uci-load')
};
maps.push(this);
}
FormMap.prototype.section = function() {
const section = new Section(this);
this.sections.push(section);
return section;
};
FormMap.prototype.options = function() {
return this.sections.flatMap(section => section.options);
};
FormMap.prototype.checkDepends = function() {
lifecycle.push('depends');
};
FormMap.prototype.parse = async function() {
lifecycle.push('parse');
for (const option of this.options()) {
if (Object.prototype.hasOwnProperty.call(this.formValues, option.option) && typeof option.write === 'function')
await option.write('settings', this.formValues[option.option]);
}
};
FormMap.prototype.load = async function() {
lifecycle.push('load');
};
FormMap.prototype.renderContents = async function() {
lifecycle.push('renderContents');
nodes.clear();
this.options().forEach(option => {
if (typeof option.renderWidget === 'function')
registerNode(option.renderWidget('settings', option.option));
});
return { map: this };
};
FormMap.prototype.render = function() {
lifecycle.push('render');
return this.renderContents();
};
const form = {
Map: FormMap,
TypedSection: Option,
NamedSection: Option,
Flag: Option,
DummyValue: Option,
Value: Option,
ListValue: Option,
DynamicList: Option,
MultiValue: Option
};
form.Value.extend = properties => {
function ExtendedOption(option) {
Option.call(this, option);
}
ExtendedOption.prototype = Object.assign(Object.create(Option.prototype), properties);
return ExtendedOption;
};
const context = {
module: { exports: {} },
console,
Promise,
Object,
String: luciString,
Array,
Set,
Error,
_: value => value,
E,
form,
fs: {},
network: { getNetworks: async () => [] },
poll: { add: () => {} },
rpc: {
declare: spec => {
rpcDeclarations.push(spec);
return async (...args) => {
rpcCalls.push({ method: spec.method, args });
if (spec.method === 'secret_status')
return { authkey_set: '0', adguard_password_set: '0' };
if (spec.method === 'openclash_bypass_status')
return { state: openclashState };
return {};
};
}
},
uci: {
load: async config => {
uciLoads.push(config);
},
get: (config, section, option) => {
if (config === 'tailscale_openclash' && section === 'settings' && option === 'enabled')
return openclashUci.enabled;
return '';
},
set: (config, section, option, value) => {
uciSets.push({ config, section, option, value });
if (config === 'tailscale_openclash' && section === 'settings' && option === 'enabled')
openclashUci.enabled = value;
return Promise.resolve('uci-set');
},
sections: () => []
},
ui: { showModal: () => {}, hideModal: () => {} },
document: {
getElementById: id => nodes.get(id) || null
},
window: {
setTimeout: (callback, delay) => {
scheduled.push({ callback, delay });
return scheduled.length;
}
}
};
const viewObject = vm.runInNewContext(`(function() {\n${source}\n})()`, Object.assign(context, {
view: { extend: value => value }
}));
return {
viewObject,
rpcDeclarations,
rpcCalls,
uciLoads,
uciSets,
scheduled,
maps,
lifecycle,
statusNodes,
currentStatusNode: () => nodes.get('openclash_bypass_status')
};
}
function scheduledOpenclashRefresh(runtime) {
return runtime.scheduled.find(entry => entry.callback.name === 'refreshOpenclashBypassStatus');
}
(async () => {
const runtime = createRuntime();
const openclashStatusDeclaration = runtime.rpcDeclarations.find(spec => spec.method === 'openclash_bypass_status');
assert(openclashStatusDeclaration, 'view must declare the OpenClash status RPC');
assert(!openclashStatusDeclaration.params, 'OpenClash status RPC must not accept caller-controlled command arguments');
const data = await runtime.viewObject.load();
assert(runtime.uciLoads.includes('tailscale_openclash'), 'load() must read the isolated OpenClash UCI package');
assert(!runtime.rpcCalls.some(call => call.method === 'openclash_bypass_status'), 'load() must not call OpenClash status RPC');
await runtime.viewObject.render(data);
assert(!runtime.rpcCalls.some(call => call.method === 'openclash_bypass_status'), 'render() must schedule, not await, OpenClash status RPC');
const initialRefresh = scheduledOpenclashRefresh(runtime);
const initialNode = runtime.currentStatusNode();
assert(initialRefresh && initialRefresh.delay === 0, 'render() must queue the OpenClash status refresh immediately after rendering');
assert(initialNode && initialNode.textContent === 'Checking ...', 'render() must create the initial OpenClash status node');
await initialRefresh.callback();
assert(initialNode.textContent === 'Enabled; 4 bypass rules are active', 'async status refresh must update the rendered status node');
const map = runtime.maps.find(candidate => candidate.config === 'tailscale');
const openclashOption = map.options().find(option => option.tab === 'openclash' && option.option === 'openclash_bypass_enabled');
assert(openclashOption, 'render() must create the OpenClash bypass toggle in its own tab');
assert(openclashOption.title === 'Protect Tailscale Traffic (Bypass OpenClash)', 'toggle must render the protection-focused title');
assert(
openclashOption.description === 'Keep Tailscale control connections, direct connections, Tailnet DNS, and subnet traffic outside OpenClash. When disabled, this traffic is handled by OpenClash; node connectivity, direct paths, subnet access, and internal DNS are no longer protected by this feature. Keep this enabled while using OpenClash.',
'toggle must render the complete disabled-impact description'
);
assert(openclashOption.cfgvalue() === '1', 'toggle must read the isolated enabled value');
const expectedStatuses = {
active: 'Enabled; 4 bypass rules are active',
waiting: 'Enabled; waiting for OpenClash nftables chains',
disabled: 'Disabled; Tailscale traffic is handled by OpenClash',
absent: 'OpenClash is not installed; bypass is not required',
unsupported: 'Unsupported; firewall4/nftables is required',
error: 'Configuration error'
};
for (const [state, expected] of Object.entries(expectedStatuses)) {
const statusRuntime = createRuntime(state);
const statusData = await statusRuntime.viewObject.load();
await statusRuntime.viewObject.render(statusData);
const refresh = scheduledOpenclashRefresh(statusRuntime);
const node = statusRuntime.currentStatusNode();
assert(refresh && node, `status ${state} must schedule a rendered refresh`);
await refresh.callback();
assert(node.textContent === expected, `status ${state} must render as: ${expected}`);
}
runtime.scheduled.length = 0;
map.formValues.openclash_bypass_enabled = '0';
await map.save(undefined, true);
assert(runtime.lifecycle.includes('parse') && runtime.lifecycle.includes('uci-save') && runtime.lifecycle.includes('load') && runtime.lifecycle.includes('renderContents'), 'save() must run the form parse, UCI save, reload, and content render lifecycle');
assert(
runtime.uciSets.length === 1 && runtime.uciSets.every(call =>
call.config === 'tailscale_openclash' &&
call.section === 'settings' &&
call.option === 'enabled' &&
call.value === '0'
),
'actual map.save() must only write tailscale_openclash.settings.enabled'
);
const savedNode = runtime.currentStatusNode();
const savedRefresh = scheduledOpenclashRefresh(runtime);
assert(savedNode && savedNode !== initialNode && runtime.statusNodes.length === 2, 'save() must replace the status node during renderContents()');
assert(savedNode.textContent === 'Checking ...', 'the replacement status node must start in checking state');
assert(savedRefresh && savedRefresh.delay === 0, 'successful save() must queue a status refresh after renderContents()');
await savedRefresh.callback();
assert(savedNode.textContent === 'Enabled; 4 bypass rules are active', 'post-save refresh must update the replacement status node');
console.log('setting OpenClash bypass tests passed');
})().catch(error => {
console.error(error);
process.exit(1);
});
@@ -1,29 +0,0 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
const source = fs.readFileSync(path.join(__dirname, '..', 'htdocs/luci-static/resources/view/tailscale/setting.js'), 'utf8');
function assert(condition, message) {
if (!condition)
throw new Error(message);
}
assert(source.includes("uci.load('tailscale_policy_routing')"), 'setting view must load the isolated policy-routing UCI package');
assert(source.includes("method: 'policy_routing_status'"), 'setting view must use the read-only policy-routing status RPC');
assert(source.includes("uci.set('tailscale_policy_routing', 'settings', 'enabled'"), 'toggle must write only the isolated policy-routing UCI package');
assert(!source.includes("uci.set('tailscale', section_id, 'mwan3_table52_precedence'"), 'toggle must not write the core Tailscale UCI package');
assert(source.includes("_('Prioritize Tailscale Routes Before mwan3')"), 'setting view must expose the mwan3 precedence toggle');
assert(source.includes("_('Give routes already present in Tailscale table 52 priority over mwan3-marked traffic. This fixes LAN clients being sent to a WAN instead of a Tailnet or routed subnet. Public destinations not present in table 52 continue through mwan3. Disabled leaves mwan3 precedence unchanged. Verify from a real LAN client or with an equivalent mwan3 fwmark; the router\\'s unmarked ip route get alone is insufficient. The feature remains blocked while table 52 has a default route, such as when using an exit node.')"), 'toggle description must explain scope, disabled impact, real-client validation, and the exit-node safety block');
assert(source.includes('!status.mwan3_present') && source.includes('!status.mwan3_earlier_mark_rule'), 'policy-routing status must surface whether mwan3 and an earlier marked rule were detected');
assert(source.includes("blocked_mwan3_priority: _('Blocked; a mwan3 rule has higher priority than 1000')"), 'policy-routing status must explain when a mwan3 rule precedes the managed priority');
const loadMatch = source.match(/load\(\)\s*\{([\s\S]*?)\n\t\},\n\n\trender/);
assert(loadMatch, 'expected setting view to define a load() method');
assert(!loadMatch[1].includes('callPolicyRoutingStatus('), 'policy-routing status must not block initial page rendering');
assert(source.includes('refreshPolicyRoutingStatus'), 'status must refresh after render');
console.log('setting policy-routing tests passed');
@@ -1,306 +0,0 @@
const fs = require('fs');
const vm = require('vm');
const source = fs.readFileSync('htdocs/luci-static/resources/view/tailscale/setting.js', 'utf8');
function assert(condition, message) {
if (!condition)
throw new Error(message);
}
const loadMatch = source.match(/load\(\)\s*\{([\s\S]*?)\n\t\},\n\n\trender/);
assert(loadMatch, 'expected setting view to define a load() method');
assert(
!loadMatch[1].includes('tailscale_adguard_dns_switch'),
'setting load() must not block first render on AdGuard DNS preflight'
);
assert(
source.includes('refreshAdguardPreflightStatus'),
'setting view should refresh AdGuard DNS preflight status after initial render'
);
assert(source.includes("method: 'secret_status'"), 'setting view must load credential presence through a dedicated RPC');
assert(source.includes("method: 'set_secrets'"), 'setting view must atomically write all changed credentials through one RPC');
assert(source.includes("params: ['authkey_set', 'authkey', 'adguard_password_set', 'adguard_password', 'api_url', 'username', 'base_ref']"), 'credential staging must identify the secret version it extends');
assert(
/load\(\)\s*\{\s*return callSecretStatus\(\)\.then/.test(source),
'setting view must finish credential migration before loading the readable UCI configuration'
);
assert(!source.includes("uci.get('tailscale', 'settings', 'adguard_password')"), 'browser must never read the persisted AdGuard password');
assert(!source.includes("uci.get('tailscale', 'settings', 'authkey')"), 'browser must never read the persisted auth key');
assert(!source.includes("uci.set('tailscale', section_id, 'adguard_password'"), 'browser must not persist AdGuard passwords in readable UCI');
assert(!source.includes("uci.set('tailscale', section_id, 'authkey'"), 'browser must not persist auth keys in readable UCI');
const helperSource = source.split('return view.extend({')[0];
const candidateValues = {
apiUrl: 'http://127.0.0.1:3001',
username: 'candidate-user',
password: 'candidate-password',
keepPassword: false,
defaultUpstreams: ['1.1.1.1', '8.8.8.8'],
tailnetUpstreams: ['[/example.test/]100.100.100.100'],
healthDomain: 'health.example.test',
expectedIps: ['10.23.0.15', '10.23.0.16']
};
async function testEnablementWrite({ persistedEnabled, value, preflightResult, preflightError, candidateRequest, persistedRequest }) {
const calls = [];
const context = {
module: { exports: {} },
console,
_: value => value,
rpc: {
declare: spec => {
if (spec.object !== 'luci.tailscale')
return function() {};
return async (...args) => {
calls.push({ type: 'rpc', object: spec.object, method: spec.method, args });
if (preflightError)
throw preflightError;
return preflightResult || {};
};
}
},
fs: {},
uci: {
get: (config, section, option) => {
calls.push({ type: 'get', config, section, option });
return persistedEnabled ? '1' : '0';
},
set: (config, section, option, nextValue) => {
calls.push({ type: 'set', config, section, option, value: nextValue });
return 'set-result';
}
}
};
vm.runInNewContext(
`${helperSource}\nmodule.exports = { buildAdguardPreflightRequest, writeAdguardDnsSwitchEnabled };`,
context
);
const builtCandidateRequest = context.module.exports.buildAdguardPreflightRequest(
persistedEnabled
? { ...candidateValues, password: '', keepPassword: true }
: candidateValues
);
let result;
let error;
try {
result = await context.module.exports.writeAdguardDnsSwitchEnabled(
'settings',
value,
candidateRequest || builtCandidateRequest,
persistedRequest || Object.assign({}, builtCandidateRequest, { enabled: persistedEnabled ? '1' : '0' })
);
} catch (e) {
error = e;
}
return { calls, result, error };
}
(async () => {
let outcome = await testEnablementWrite({
persistedEnabled: false,
value: '1',
preflightResult: { adguard_process: 'pass', health_check: 'fail', ready: 'fail' }
});
assert(outcome.calls.filter(call => call.type === 'rpc').length === 1, '0 -> 1 must run one fresh preflight RPC');
const preflightCall = outcome.calls.find(call => call.type === 'rpc');
assert(preflightCall.object === 'luci.tailscale' && preflightCall.method === 'adguard_preflight', 'preflight must use the dedicated Tailscale RPC method');
assert(preflightCall.args[0] === '1', 'preflight RPC must opt into candidate configuration');
assert(preflightCall.args[1] === candidateValues.apiUrl, 'preflight RPC must receive the candidate API URL');
assert(preflightCall.args[4] === candidateValues.password, 'preflight RPC must receive a newly entered candidate password');
assert(preflightCall.args[5] === '1.1.1.1\n8.8.8.8', 'preflight RPC must preserve candidate upstream list boundaries');
assert(preflightCall.args[8] === '10.23.0.15\n10.23.0.16', 'preflight RPC must receive candidate expected IPs');
assert(outcome.calls.every(call => call.type !== 'set'), 'failed initial enablement must not write the flag');
assert(outcome.error && /status checks/i.test(outcome.error.message), 'failed initial enablement must reject save with a useful error');
outcome = await testEnablementWrite({
persistedEnabled: false,
value: '1',
preflightResult: { adguard_process: 'pass', health_check: 'pass', ready: 'pass' }
});
assert(outcome.calls.filter(call => call.type === 'rpc').length === 1, 'passing 0 -> 1 must still use a fresh preflight');
assert(outcome.calls.some(call => call.type === 'set' && call.value === '1'), 'passing initial enablement must write the flag');
assert(outcome.result === 'set-result', 'write hook must preserve the Promise-capable UCI write result');
outcome = await testEnablementWrite({
persistedEnabled: true,
value: '1',
preflightError: new Error('transient health failure')
});
assert(outcome.calls.every(call => call.type !== 'rpc'), 'unchanged already-enabled saves must not rerun or gate on preflight');
assert(outcome.calls.some(call => call.type === 'set' && call.value === '1'), 'already-enabled saves must remain writable');
const persistedRequest = Object.assign({}, vm.runInNewContext(
`${helperSource}\nbuildAdguardPreflightRequest(${JSON.stringify({ ...candidateValues, password: '', keepPassword: true })})`,
{
module: { exports: {} },
console,
_: value => value,
rpc: { declare: () => function() {} },
fs: {},
uci: {}
}
), { enabled: '1' });
const changedEndpointWithoutPassword = Object.assign({}, persistedRequest, {
api_url: 'http://changed.example:3000'
});
outcome = await testEnablementWrite({
persistedEnabled: true,
value: '1',
candidateRequest: changedEndpointWithoutPassword,
persistedRequest
});
assert(outcome.error && /password/i.test(outcome.error.message), 'changing a bound AdGuard endpoint must require the password again');
assert(outcome.calls.every(call => call.type !== 'rpc' && call.type !== 'set'), 'missing replacement password must fail before preflight or UCI mutation');
const changedEndpointWithPassword = Object.assign({}, changedEndpointWithoutPassword, {
password_set: '1',
password: 'replacement-password'
});
outcome = await testEnablementWrite({
persistedEnabled: true,
value: '1',
candidateRequest: changedEndpointWithPassword,
persistedRequest,
preflightResult: { ready: 'pass' }
});
assert(outcome.calls.filter(call => call.type === 'rpc').length === 1, 'changed AdGuard endpoint with a replacement password must rerun preflight');
assert(outcome.calls.some(call => call.type === 'set' && call.value === '1'), 'passing changed-endpoint preflight must save the enabled flag');
const changedHealthDomain = Object.assign({}, persistedRequest, {
health_domain: 'changed-health.example.test'
});
outcome = await testEnablementWrite({
persistedEnabled: true,
value: '1',
candidateRequest: changedHealthDomain,
persistedRequest,
preflightResult: { ready: 'pass' }
});
assert(outcome.calls.filter(call => call.type === 'rpc').length === 1, 'changed AdGuard health configuration must rerun preflight');
outcome = await testEnablementWrite({
persistedEnabled: false,
value: '0',
preflightError: new Error('AdGuard unavailable')
});
assert(outcome.calls.every(call => call.type !== 'rpc'), 'disabled saves must not run preflight');
assert(outcome.calls.some(call => call.type === 'set' && call.value === '0'), 'disabled saves must remain writable');
const keepPasswordRequest = vm.runInNewContext(
`${helperSource}\nbuildAdguardPreflightRequest(${JSON.stringify({ ...candidateValues, password: '', keepPassword: true })})`,
{
module: { exports: {} },
console,
_: value => value,
rpc: { declare: () => function() {} },
fs: {},
uci: {}
}
);
assert(keepPasswordRequest.password_set === '0', 'blank password must tell preflight to retain the persisted secret');
assert(keepPasswordRequest.password === '', 'persisted password must not be copied into the browser RPC request');
assert(
source.includes('buildAdguardPreflightRequest({') && source.includes('writeAdguardDnsSwitchEnabled(section_id, value, candidateRequest, persistedAdguardConfig)'),
'the AdGuard flag write hook must build candidate values from the live form before preflight'
);
const atomicContext = {
module: { exports: {} },
console,
_: value => value,
rpc: { declare: () => function() {} },
fs: {},
ui: { showModal: () => {} },
uci: {},
E: () => ({})
};
vm.runInNewContext(
`${helperSource}\nmodule.exports = { buildSecretUpdateRequest, saveMapWithSecrets };`,
atomicContext
);
const secretRequest = atomicContext.module.exports.buildSecretUpdateRequest({
authkey: 'new-auth-key',
adguardPassword: 'new-adguard-password',
adguardApiUrl: 'https://adguard.example:3000',
adguardUsername: 'root'
});
assert(secretRequest.authkey_set === '1' && secretRequest.adguard_password_set === '1', 'one atomic request must carry both changed credentials');
const spacedPasswordRequest = atomicContext.module.exports.buildSecretUpdateRequest({
adguardPassword: ' password with spaces '
});
assert(spacedPasswordRequest.adguard_password === ' password with spaces ', 'AdGuard passwords must preserve leading and trailing spaces');
assert(!source.includes("String(adguardPasswordOption.formvalue(section_id) || '').trim()"), 'live AdGuard password input must not be trimmed before preflight');
assert(source.includes("uci.set('tailscale', 'settings', 'secrets_ref'"), 'staged credentials must be activated through a UCI-managed version reference');
assert(source.includes('saveInFlight'), 'concurrent Save and Save & Apply actions must share one in-flight save transaction');
const saveCalls = [];
const map = {
checkDepends: () => saveCalls.push('depends'),
parse: async () => saveCalls.push('parse'),
data: { save: async () => saveCalls.push('uci-save') },
load: async () => saveCalls.push('load'),
renderContents: async () => saveCalls.push('render')
};
await atomicContext.module.exports.saveMapWithSecrets(
map,
undefined,
true,
async () => { saveCalls.push('stage'); return { ref: 'staged-ref' }; },
async staged => saveCalls.push(`after:${staged.ref}`)
);
assert(saveCalls.join(',') === 'depends,parse,stage,uci-save,load,after:staged-ref,render', 'credentials must be staged before the UCI reference is saved and remain inactive until apply');
let afterSaveCalled = false;
const failingMap = {
checkDepends: () => {},
parse: async () => {},
data: { save: async () => { throw new Error('uci save failed'); } },
load: async () => {},
renderContents: async () => {}
};
try {
await atomicContext.module.exports.saveMapWithSecrets(
failingMap,
undefined,
true,
async () => ({ ref: 'orphaned-but-inactive-ref' }),
async () => { afterSaveCalled = true; }
);
} catch (e) {}
assert(!afterSaveCalled, 'failed UCI persistence must not report the staged credential reference as saved');
let uciSaveCalled = false;
const stageFailingMap = {
checkDepends: () => {},
parse: async () => {},
data: { save: async () => { uciSaveCalled = true; } },
load: async () => {},
renderContents: async () => {}
};
try {
await atomicContext.module.exports.saveMapWithSecrets(
stageFailingMap,
undefined,
true,
async () => { throw new Error('secret staging failed'); },
async () => {}
);
} catch (e) {}
assert(!uciSaveCalled, 'failed secret staging must not save a UCI reference or any other form changes');
console.log('setting preflight tests passed');
})().catch(error => {
console.error(error);
process.exit(1);
});
@@ -1,579 +0,0 @@
#!/bin/sh
set -eu
ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
SCRIPT="$ROOT_DIR/root/usr/sbin/tailscale_adguard_dns_switch"
TMP_DIR="${TMPDIR:-/tmp}/tailscale-adguard-dns-test.$$"
cleanup() {
rm -rf "$TMP_DIR"
}
trap cleanup EXIT
fail() {
echo "FAIL: $*" >&2
exit 1
}
assert_eq() {
expected="$1"
actual="$2"
message="$3"
[ "$expected" = "$actual" ] || fail "$message
expected: $expected
actual: $actual"
}
assert_contains() {
needle="$1"
haystack="$2"
message="$3"
printf '%s' "$haystack" | grep -F -- "$needle" >/dev/null || fail "$message
missing: $needle
actual: $haystack"
}
assert_occurrences() {
expected="$1"
needle="$2"
file="$3"
message="$4"
actual="$(grep -F -c -- "$needle" "$file" || true)"
[ "$expected" = "$actual" ] || fail "$message
expected occurrences: $expected
actual occurrences: $actual
needle: $needle"
}
make_fake_uci() {
cat >"$TMP_DIR/uci" <<'SH'
#!/bin/sh
case "$*" in
"-q get tailscale.settings.adguard_dns_switch_enabled") echo "${UCI_SWITCH_ENABLED:-1}" ;;
"-q get tailscale.settings.accept_dns") echo "${UCI_ACCEPT_DNS:-1}" ;;
"-q get tailscale.settings.adguard_api_url") echo "${UCI_API_URL:-http://127.0.0.1:3000}" ;;
"-q get tailscale.settings.adguard_username") echo "${UCI_API_USER:-}" ;;
"-q get tailscale.settings.adguard_password") echo "${UCI_API_PASS:-}" ;;
"-q get tailscale.settings.adguard_health_domain") echo "${UCI_HEALTH_DOMAIN:-service.example.test}" ;;
"-q get tailscale.settings.adguard_check_interval") echo "${UCI_CHECK_INTERVAL:-10}" ;;
"-q get tailscale.settings.adguard_success_threshold") echo "${UCI_SUCCESS_THRESHOLD:-2}" ;;
"-q get tailscale.settings.adguard_failure_threshold") echo "${UCI_FAILURE_THRESHOLD:-2}" ;;
"-q get tailscale.settings.adguard_default_upstreams")
[ "${UCI_EMPTY_DEFAULT_UPSTREAMS:-0}" = "1" ] && exit 1
echo "[/lan/]127.0.0.1:5353 9.9.9.9 100.100.100.100 149.112.112.112 9.9.9.9"
;;
"-q get tailscale.settings.adguard_tailnet_upstreams")
echo "[/example.ts.net/]100.100.100.100 [/internal.example/]100.100.100.100"
;;
"-q get tailscale.settings.adguard_health_expected_ips") echo "10.23.0.15" ;;
"-q get network.lan.ipaddr") echo "${UCI_LAN_IP:-192.168.100.1}" ;;
"-q get dhcp.lan.dhcp_option") echo "${UCI_DHCP_OPTIONS:-6,192.168.100.1}" ;;
"show tailscale.settings.adguard_default_upstreams")
[ "${UCI_EMPTY_DEFAULT_UPSTREAMS:-0}" = "1" ] && exit 0
echo "tailscale.settings.adguard_default_upstreams='[/lan/]127.0.0.1:5353' '9.9.9.9' '100.100.100.100' '149.112.112.112' '9.9.9.9'"
;;
"show tailscale.settings.adguard_tailnet_upstreams")
echo "tailscale.settings.adguard_tailnet_upstreams='[/example.ts.net/]100.100.100.100' '[/internal.example/]100.100.100.100'"
;;
"show tailscale.settings.adguard_health_expected_ips")
echo "tailscale.settings.adguard_health_expected_ips='10.23.0.15'"
;;
*) exit 1 ;;
esac
SH
chmod +x "$TMP_DIR/uci"
}
make_fake_commands() {
make_fake_uci
cat >"$TMP_DIR/secrets" <<'SH'
#!/bin/sh
printf '%s\n' "$*" >>"${SECRETS_LOG:?}"
case "${1:-}" in
adguard-password-for)
requested_url="${2:-}"
requested_user="${3:-}"
bound_url="${SECRET_BOUND_URL:-${UCI_API_URL:-http://127.0.0.1:3000}}"
bound_url="${bound_url%/}"
bound_user="${SECRET_BOUND_USER:-${UCI_API_USER:-}}"
[ "$requested_url" = "$bound_url" ] && [ "$requested_user" = "$bound_user" ] || exit 1
[ -n "${SECRET_PASSWORD:-}" ] || exit 1
printf '%s\n' "$SECRET_PASSWORD"
;;
*) exit 1 ;;
esac
SH
chmod +x "$TMP_DIR/secrets"
cat >"$TMP_DIR/nslookup" <<'SH'
#!/bin/sh
printf '%s\n' "$*" >>"${NSLOOKUP_LOG:?}"
if [ "${NSLOOKUP_HEALTH:-ok}" = "ok" ]; then
cat <<'OUT'
Server: 100.100.100.100
Address: 100.100.100.100:53
Name: service.example.test
Address: 10.23.0.15
OUT
else
cat <<'OUT'
Server: 100.100.100.100
Address: 100.100.100.100:53
** server can't find service.example.test: SERVFAIL
OUT
exit 1
fi
SH
chmod +x "$TMP_DIR/nslookup"
cat >"$TMP_DIR/curl" <<'SH'
#!/bin/sh
printf '%s\n' "$*" >>"${CURL_LOG:?}"
read_config=0
for arg in "$@"; do
if [ "$read_config" = "1" ]; then
cat "$arg" >>"${CURL_AUTH_LOG:?}"
read_config=0
elif [ "$arg" = "--config" ]; then
read_config=1
fi
done
case "$*" in
*"/control/status"*) echo '{"running":true,"dns_port":53}' ;;
*"/control/dns_info"*)
[ "${CURL_DNS_INFO_FAIL:-0}" = "1" ] && exit 1
cat "${DNS_INFO_JSON:?}"
;;
*"/control/dns_config"*)
body=
while [ "$#" -gt 0 ]; do
if [ "$1" = "--data-binary" ]; then
shift
body="${1#@}"
fi
shift
done
[ "${CURL_DNS_CONFIG_FAIL:-0}" = "1" ] && exit 1
[ -n "$body" ] && cat "$body" >"${POSTED_JSON:?}"
echo OK
;;
*"/control/test_upstream_dns"*)
body=
while [ "$#" -gt 0 ]; do
if [ "$1" = "--data-binary" ]; then
shift
body="${1#@}"
fi
shift
done
[ -n "$body" ] && cat "$body" >"${TEST_UPSTREAM_JSON:?}"
[ "${CURL_TEST_UPSTREAM_FAIL:-0}" = "1" ] && exit 1
if [ -n "${CURL_TEST_UPSTREAM_RESPONSE:-}" ]; then
printf '%s\n' "$CURL_TEST_UPSTREAM_RESPONSE"
else
cat <<'JSON'
{"[/lan/]127.0.0.1:5353":"OK","9.9.9.9":"OK","149.112.112.112":"OK"}
JSON
fi
;;
*"/control/cache_clear"*)
[ "${CURL_CACHE_CLEAR_FAIL:-0}" = "1" ] && exit 1
echo OK
;;
*) echo OK ;;
esac
SH
chmod +x "$TMP_DIR/curl"
cat >"$TMP_DIR/flock" <<'SH'
#!/bin/sh
printf '%s\n' "$*" >>"${FLOCK_LOG:?}"
[ "${FLOCK_FAIL:-0}" = "0" ]
SH
chmod +x "$TMP_DIR/flock"
cat >"$TMP_DIR/logger" <<'SH'
#!/bin/sh
printf '%s\n' "$*" >>"${LOGGER_LOG:?}"
SH
chmod +x "$TMP_DIR/logger"
cat >"$TMP_DIR/sleep" <<'SH'
#!/bin/sh
printf '%s\n' "$*" >>"${SLEEP_LOG:?}"
count_file="${SLEEP_COUNT_FILE:?}"
count="$(cat "$count_file" 2>/dev/null || echo 0)"
count=$((count + 1))
printf '%s\n' "$count" >"$count_file"
if [ "$count" -ge "${SLEEP_MAX_CALLS:-1}" ]; then
kill -TERM "$PPID"
fi
exit 0
SH
chmod +x "$TMP_DIR/sleep"
cat >"$TMP_DIR/pgrep" <<'SH'
#!/bin/sh
[ "${PGREP_ADGUARD:-1}" = "1" ] && exit 0
exit 1
SH
chmod +x "$TMP_DIR/pgrep"
cat >"$TMP_DIR/netstat" <<'SH'
#!/bin/sh
if [ "${NETSTAT_ADGUARD_53:-1}" = "1" ]; then
echo "udp 0 0 0.0.0.0:53 0.0.0.0:* 1234/AdGuardHome"
else
echo "udp 0 0 0.0.0.0:53 0.0.0.0:* 1234/dnsmasq"
fi
SH
chmod +x "$TMP_DIR/netstat"
cat >"$TMP_DIR/ubus" <<'SH'
#!/bin/sh
cat <<'OUT'
{"ipv4-address":[{"address":"192.168.100.1","mask":24}]}
OUT
SH
chmod +x "$TMP_DIR/ubus"
cat >"$TMP_DIR/jq" <<'SH'
#!/bin/sh
printf '%s\n' "$*" >>"${JQ_LOG:?}"
[ "${JQ_REPLACE_FAIL:-0}" = "1" ] && printf '%s\n' "$*" | grep -F -- '--rawfile upstreams' >/dev/null && exit 1
exec "${REAL_JQ:?}" "$@"
SH
chmod +x "$TMP_DIR/jq"
PATH="$TMP_DIR:$PATH"
export PATH
}
prepare_api_json() {
CURL_LOG="$TMP_DIR/curl.log"
CURL_AUTH_LOG="$TMP_DIR/curl-auth.log"
FLOCK_LOG="$TMP_DIR/flock.log"
LOGGER_LOG="$TMP_DIR/logger.log"
DNS_INFO_JSON="$TMP_DIR/dns-info.json"
POSTED_JSON="$TMP_DIR/posted.json"
TEST_UPSTREAM_JSON="$TMP_DIR/test-upstream.json"
JQ_LOG="$TMP_DIR/jq.log"
NSLOOKUP_LOG="$TMP_DIR/nslookup-call.log"
SLEEP_LOG="$TMP_DIR/sleep.log"
SLEEP_COUNT_FILE="$TMP_DIR/sleep.count"
SECRETS_LOG="$TMP_DIR/secrets.log"
REAL_JQ="${REAL_JQ:-$(command -v jq)}"
STATE_DIR="$TMP_DIR/state"
export CURL_LOG CURL_AUTH_LOG FLOCK_LOG LOGGER_LOG DNS_INFO_JSON POSTED_JSON TEST_UPSTREAM_JSON JQ_LOG NSLOOKUP_LOG SLEEP_LOG SLEEP_COUNT_FILE SECRETS_LOG REAL_JQ STATE_DIR
: >"$CURL_AUTH_LOG"
: >"$FLOCK_LOG"
: >"$SECRETS_LOG"
printf '0\n' >"$SLEEP_COUNT_FILE"
cat >"$DNS_INFO_JSON" <<'JSON'
{"upstream_dns":["1.1.1.1"],"bootstrap_dns":["223.5.5.5"],"fallback_dns":["119.29.29.29"],"cache_enabled":false,"cache_size":4194304}
JSON
}
run_script() {
UCI_CMD="$TMP_DIR/uci" \
SECRETS_BIN="$TMP_DIR/secrets" \
CURL_CMD="$TMP_DIR/curl" \
FLOCK_CMD="$TMP_DIR/flock" \
LOGGER_CMD="$TMP_DIR/logger" \
NSLOOKUP_CMD="$TMP_DIR/nslookup" \
PGREP_CMD="$TMP_DIR/pgrep" \
NETSTAT_CMD="$TMP_DIR/netstat" \
UBUS_CMD="$TMP_DIR/ubus" \
JQ_CMD="$TMP_DIR/jq" \
SLEEP_CMD="$TMP_DIR/sleep" \
"$SCRIPT" "$@"
}
test_persisted_password_is_bound_to_saved_endpoint() {
: >"$CURL_AUTH_LOG"
: >"$SECRETS_LOG"
UCI_API_URL='http://attacker.example:3000' \
UCI_API_USER='saved-user' \
UCI_API_PASS='legacy-readable-secret' \
SECRET_BOUND_URL='http://saved.example:3000' \
SECRET_BOUND_USER='saved-user' \
SECRET_PASSWORD='protected-secret' \
run_script --preflight >/dev/null || true
if grep -F -- 'legacy-readable-secret' "$CURL_AUTH_LOG" >/dev/null || grep -F -- 'protected-secret' "$CURL_AUTH_LOG" >/dev/null; then
fail "a changed AdGuard endpoint must not receive either legacy or protected saved passwords"
fi
assert_contains 'adguard-password-for http://attacker.example:3000 saved-user' "$(cat "$SECRETS_LOG")" "runtime must verify the endpoint binding before loading the protected password"
}
test_preflight_uses_candidate_configuration() {
: >"$CURL_LOG"
: >"$CURL_AUTH_LOG"
: >"$NSLOOKUP_LOG"
out="$(
TAILSCALE_ADGUARD_PREFLIGHT_CANDIDATE=1 \
TAILSCALE_ADGUARD_PREFLIGHT_API_URL='http://candidate.example:3000' \
TAILSCALE_ADGUARD_PREFLIGHT_USERNAME='candidate-user' \
TAILSCALE_ADGUARD_PREFLIGHT_PASSWORD_SET=1 \
TAILSCALE_ADGUARD_PREFLIGHT_PASSWORD='candidate-secret' \
TAILSCALE_ADGUARD_PREFLIGHT_DEFAULT_UPSTREAMS="$(printf '1.1.1.1\n8.8.8.8')" \
TAILSCALE_ADGUARD_PREFLIGHT_TAILNET_UPSTREAMS='[/candidate.example/]100.100.100.100' \
TAILSCALE_ADGUARD_PREFLIGHT_HEALTH_DOMAIN='candidate-health.example' \
TAILSCALE_ADGUARD_PREFLIGHT_EXPECTED_IPS='10.23.0.15' \
run_script --preflight
)"
assert_contains 'ready=pass' "$out" "candidate configuration should pass preflight"
assert_eq '{"upstream_dns":["1.1.1.1","8.8.8.8"]}' "$(cat "$TEST_UPSTREAM_JSON")" "preflight API payload must use candidate upstreams instead of persisted UCI"
assert_contains 'candidate-health.example 100.100.100.100' "$(cat "$NSLOOKUP_LOG")" "health check must use the candidate domain"
assert_contains 'http://candidate.example:3000/control/status' "$(cat "$CURL_LOG")" "AdGuard API checks must use the candidate URL"
assert_contains 'user = "candidate-user:candidate-secret"' "$(cat "$CURL_AUTH_LOG")" "curl must receive candidate credentials through a protected config"
if grep -F -- 'candidate-secret' "$CURL_LOG" >/dev/null || grep -F -- '-u ' "$CURL_LOG" >/dev/null; then
fail "AdGuard credentials must not appear in curl argv"
fi
}
test_profile_generation() {
up="$(run_script --profile up)"
down="$(run_script --profile down)"
assert_eq "[/lan/]127.0.0.1:5353
[/example.ts.net/]100.100.100.100
[/internal.example/]100.100.100.100
9.9.9.9
149.112.112.112
100.100.100.100" "$up" "up profile should place tailnet conditionals before public defaults"
assert_eq "[/lan/]127.0.0.1:5353
9.9.9.9
149.112.112.112" "$down" "down profile should remove 100.100.100.100 and dedupe defaults"
}
test_health_check() {
NSLOOKUP_HEALTH=ok run_script --check-health >/dev/null
nslookup_call="$(cat "$TMP_DIR/nslookup-call.log")"
assert_contains "service.example.test 100.100.100.100" "$nslookup_call" "health check must use hard-coded Tailscale DNS"
if NSLOOKUP_HEALTH=fail run_script --check-health >/dev/null 2>&1; then
fail "failed nslookup must make health check fail"
fi
}
test_preflight_reports_failures() {
out="$(NETSTAT_ADGUARD_53=0 run_script --preflight || true)"
assert_contains "port_53_adguard=fail" "$out" "preflight should report port 53 ownership failure"
assert_contains "ready=fail" "$out" "preflight should not be ready when a required check fails"
}
test_preflight_requires_api_post_test() {
out="$(CURL_TEST_UPSTREAM_FAIL=1 run_script --preflight || true)"
assert_contains "adguard_api=fail" "$out" "preflight should report AdGuard API test POST failure"
assert_contains "ready=fail" "$out" "preflight should not be ready when AdGuard API test POST fails"
if CURL_TEST_UPSTREAM_FAIL=1 run_script --preflight >/dev/null 2>&1; then
fail "preflight should fail when AdGuard test_upstream_dns POST fails"
fi
if grep -F '/control/dns_config' "$CURL_LOG" >/dev/null; then
fail "preflight must not POST to /control/dns_config"
fi
assert_contains "/control/test_upstream_dns" "$(cat "$CURL_LOG")" "preflight should POST to test_upstream_dns"
assert_eq '{"upstream_dns":["[/lan/]127.0.0.1:5353","9.9.9.9","149.112.112.112"]}' "$(cat "$TEST_UPSTREAM_JSON")" "preflight API payload should come from configured default upstreams"
assert_contains "--rawfile upstreams" "$(cat "$JQ_LOG")" "preflight API payload should be built from the validated upstream file with jq"
}
test_preflight_rejects_upstream_error_response() {
out="$(CURL_TEST_UPSTREAM_RESPONSE='{"[/lan/]127.0.0.1:5353":"OK","9.9.9.9":"upstream timeout","149.112.112.112":"OK"}' run_script --preflight || true)"
assert_contains "adguard_api=fail" "$out" "preflight should fail the AdGuard API check when an upstream test reports an error"
assert_contains "ready=fail" "$out" "preflight should not be ready when an upstream test reports an error"
}
test_preflight_requires_configured_default_upstream() {
: >"$LOGGER_LOG"
out="$(UCI_EMPTY_DEFAULT_UPSTREAMS=1 run_script --preflight || true)"
assert_contains "adguard_api=fail" "$out" "preflight should fail the API check when no default upstream is configured"
assert_contains "ready=fail" "$out" "preflight should not be ready without a default upstream"
assert_contains "requires at least one valid default upstream" "$(cat "$LOGGER_LOG")" "empty default upstream failure should be logged clearly"
}
test_preflight_does_not_require_accept_dns() {
out="$(UCI_ACCEPT_DNS=0 run_script --preflight)"
assert_contains "ready=pass" "$out" "preflight should pass when direct Tailscale DNS health checks work with accept_dns disabled"
if printf '%s' "$out" | grep -F 'accept_dns=' >/dev/null; then
fail "accept_dns should not be a blocking AdGuard DNS auto switch preflight check"
fi
}
test_apply_profile_preserves_dns_info() {
: >"$FLOCK_LOG"
run_script --apply-profile up
posted="$(cat "$POSTED_JSON")"
assert_contains '"bootstrap_dns":["223.5.5.5"]' "$posted" "API payload should preserve bootstrap_dns"
assert_contains '"fallback_dns":["119.29.29.29"]' "$posted" "API payload should preserve fallback_dns"
assert_contains '"upstream_dns":["[/lan/]127.0.0.1:5353","[/example.ts.net/]100.100.100.100","[/internal.example/]100.100.100.100","9.9.9.9","149.112.112.112","100.100.100.100"]' "$posted" "API payload should replace upstream_dns"
assert_contains "--rawfile upstreams" "$(cat "$JQ_LOG")" "profile application should use jq with raw upstream file input"
assert_contains "--connect-timeout 3" "$(cat "$CURL_LOG")" "curl calls should include a connect timeout"
assert_contains "--max-time 10" "$(cat "$CURL_LOG")" "curl calls should include a max time"
assert_contains "/control/cache_clear" "$(cat "$CURL_LOG")" "profile application should always clear AdGuard cache after switching"
assert_contains "-x 9" "$(cat "$FLOCK_LOG")" "profile application must acquire an exclusive lock"
}
test_down_profile_uses_saved_snapshot_when_config_is_cleared() {
rm -rf "$STATE_DIR"
mkdir -p "$STATE_DIR"
run_script --apply-profile up
[ -s "$STATE_DIR/down_profile" ] || fail "switching up must persist a recoverable down profile"
: >"$CURL_LOG"
rm -f "$POSTED_JSON"
if ! UCI_EMPTY_DEFAULT_UPSTREAMS=1 run_script --apply-profile down; then
fail "saved down-profile recovery failed
logger: $(cat "$LOGGER_LOG")
curl: $(cat "$CURL_LOG")"
fi
assert_contains '"upstream_dns":["[/lan/]127.0.0.1:5353","9.9.9.9","149.112.112.112"]' "$(cat "$POSTED_JSON")" "a saved down profile must restore public DNS after the new UCI defaults are cleared"
assert_eq "down" "$(cat "$STATE_DIR/current_profile")" "successful recovery must record the down profile"
}
test_apply_profile_fails_when_lock_is_unavailable() {
: >"$CURL_LOG"
if FLOCK_FAIL=1 run_script --apply-profile up >/dev/null 2>&1; then
fail "profile application must fail when the exclusive lock cannot be acquired"
fi
if grep -F '/control/dns_info' "$CURL_LOG" >/dev/null; then
fail "profile application must not touch AdGuard before acquiring the lock"
fi
}
test_symlink_state_directory_is_rejected() {
evil_dir="$TMP_DIR/evil-state-target"
rm -rf "$STATE_DIR" "$evil_dir"
mkdir -p "$evil_dir"
ln -s "$evil_dir" "$STATE_DIR"
if run_script --apply-profile up >/dev/null 2>&1; then
fail "a symlink state directory must be rejected"
fi
[ -z "$(find "$evil_dir" -mindepth 1 -print -quit)" ] || fail "a rejected symlink state directory must not receive root-written files"
rm -f "$STATE_DIR"
mkdir -p "$STATE_DIR"
}
test_empty_profile_does_not_write_dns_config() {
: >"$CURL_LOG"
rm -f "$POSTED_JSON"
if UCI_EMPTY_DEFAULT_UPSTREAMS=1 run_script --apply-profile down >/dev/null 2>&1; then
fail "empty down profile should make apply-profile fail"
fi
if [ -e "$POSTED_JSON" ]; then
fail "empty profile must not write dns_config payload"
fi
if grep -F '/control/dns_config' "$CURL_LOG" >/dev/null; then
fail "empty profile must not POST to /control/dns_config"
fi
}
test_run_loop_applies_down_profile_when_initial_health_fails() {
: >"$CURL_LOG"
: >"$SLEEP_LOG"
rm -f "$POSTED_JSON"
mkdir -p "$STATE_DIR"
printf 'up\n' >"$STATE_DIR/current_profile"
printf '0\n' >"$SLEEP_COUNT_FILE"
UCI_FAILURE_THRESHOLD=1 NSLOOKUP_HEALTH=fail UCI_CHECK_INTERVAL=1 run_script --run >/dev/null 2>&1 || true
posted="$(cat "$POSTED_JSON")"
assert_contains '"upstream_dns":["[/lan/]127.0.0.1:5353","9.9.9.9","149.112.112.112"]' "$posted" "run loop should apply down profile when health initially fails"
assert_contains "1" "$(cat "$SLEEP_LOG")" "run loop should continue after applying the down profile"
}
test_run_loop_runs_static_preflight_once_before_health_polling() {
rm -rf "$STATE_DIR"
mkdir -p "$STATE_DIR"
: >"$CURL_LOG"
: >"$NSLOOKUP_LOG"
: >"$SLEEP_LOG"
printf 'up\n' >"$STATE_DIR/current_profile"
printf '0\n' >"$SLEEP_COUNT_FILE"
SLEEP_MAX_CALLS=3 UCI_CHECK_INTERVAL=1 run_script --run >/dev/null 2>&1 || true
assert_occurrences 1 "/control/test_upstream_dns" "$CURL_LOG" "static AdGuard API preflight should run only once after it succeeds"
assert_occurrences 3 "service.example.test 100.100.100.100" "$NSLOOKUP_LOG" "health polling should continue after static preflight succeeds"
}
test_run_loop_retries_static_preflight_failures_without_exit_churn() {
rm -rf "$STATE_DIR"
mkdir -p "$STATE_DIR"
: >"$LOGGER_LOG"
: >"$SLEEP_LOG"
printf '0\n' >"$SLEEP_COUNT_FILE"
SLEEP_MAX_CALLS=3 NETSTAT_ADGUARD_53=0 UCI_CHECK_INTERVAL=1 run_script --run >/dev/null 2>&1 || true
assert_eq "3" "$(cat "$SLEEP_COUNT_FILE")" "static preflight failure should stay in the worker and retry internally"
assert_occurrences 1 "static preflight failed: port_53_adguard" "$LOGGER_LOG" "identical static preflight failures should be rate-limited without hiding the first"
printf '0\n' >"$SLEEP_COUNT_FILE"
SLEEP_MAX_CALLS=1 PGREP_ADGUARD=0 UCI_CHECK_INTERVAL=1 run_script --run >/dev/null 2>&1 || true
assert_occurrences 1 "static preflight failed: adguard_process" "$LOGGER_LOG" "a different static preflight failure should log immediately"
}
test_profile_failures_are_rate_limited_by_signature() {
rm -rf "$STATE_DIR"
mkdir -p "$STATE_DIR"
: >"$LOGGER_LOG"
CURL_DNS_INFO_FAIL=1 run_script --apply-profile up >/dev/null 2>&1 || true
CURL_DNS_INFO_FAIL=1 run_script --apply-profile up >/dev/null 2>&1 || true
assert_occurrences 1 "failed to read AdGuard DNS config" "$LOGGER_LOG" "repeated profile read failures should be rate-limited"
JQ_REPLACE_FAIL=1 run_script --apply-profile up >/dev/null 2>&1 || true
JQ_REPLACE_FAIL=1 run_script --apply-profile up >/dev/null 2>&1 || true
assert_occurrences 1 "failed to build AdGuard DNS config payload" "$LOGGER_LOG" "repeated profile build failures should be rate-limited"
CURL_DNS_CONFIG_FAIL=1 run_script --apply-profile up >/dev/null 2>&1 || true
CURL_DNS_CONFIG_FAIL=1 run_script --apply-profile up >/dev/null 2>&1 || true
assert_occurrences 1 "failed to write AdGuard DNS up profile" "$LOGGER_LOG" "repeated profile write failures should be rate-limited"
CURL_CACHE_CLEAR_FAIL=1 run_script --apply-profile up >/dev/null 2>&1
CURL_CACHE_CLEAR_FAIL=1 run_script --apply-profile up >/dev/null 2>&1
assert_occurrences 1 "AdGuard cache clear failed after up profile switch" "$LOGGER_LOG" "repeated cache-clear failures should be rate-limited"
}
mkdir -p "$TMP_DIR"
REAL_JQ="${REAL_JQ:-$(command -v jq)}"
export REAL_JQ
make_fake_commands
prepare_api_json
test_profile_generation
test_health_check
test_preflight_reports_failures
test_preflight_requires_api_post_test
test_preflight_rejects_upstream_error_response
test_preflight_requires_configured_default_upstream
test_preflight_does_not_require_accept_dns
test_preflight_uses_candidate_configuration
test_persisted_password_is_bound_to_saved_endpoint
test_apply_profile_preserves_dns_info
test_down_profile_uses_saved_snapshot_when_config_is_cleared
test_apply_profile_fails_when_lock_is_unavailable
test_symlink_state_directory_is_rejected
test_empty_profile_does_not_write_dns_config
test_run_loop_applies_down_profile_when_initial_health_fails
test_run_loop_runs_static_preflight_once_before_health_polling
test_run_loop_retries_static_preflight_failures_without_exit_churn
test_profile_failures_are_rate_limited_by_signature
echo "tailscale_adguard_dns_switch tests passed"
@@ -1,736 +0,0 @@
#!/bin/sh
set -eu
ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
SCRIPT="$ROOT_DIR/root/usr/sbin/tailscale_helper"
TMP_DIR="${TMPDIR:-/tmp}/tailscale-helper-test.$$"
cleanup() {
rm -rf "$TMP_DIR"
}
trap cleanup EXIT
fail() {
echo "FAIL: $*" >&2
exit 1
}
assert_contains() {
needle="$1"
haystack="$2"
message="$3"
printf '%s' "$haystack" | grep -F -- "$needle" >/dev/null || fail "$message
missing: $needle"
}
assert_not_contains() {
needle="$1"
haystack="$2"
message="$3"
if printf '%s' "$haystack" | grep -F -- "$needle" >/dev/null; then
fail "$message
unexpected: $needle"
fi
}
body="$(cat "$SCRIPT")"
assert_contains "uci -q delete network.tailscale" "$body" "helper should remove the legacy LuCI network interface wrapper"
assert_contains "add_list firewall.tszone.device='tailscale0'" "$body" "firewall zone should bind directly to tailscale0"
assert_contains "apply_firewall_changes" "$body" "helper should apply committed firewall changes through one checked path"
assert_not_contains "set network.tailscale='interface'" "$body" "helper must not recreate a LuCI network interface for tailscale0"
assert_not_contains "set network.tailscale.proto" "$body" "helper must not configure a netifd protocol for tailscale0"
assert_not_contains "set network.ts_subnet" "$body" "helper must not create static netifd routes for Tailscale subnet routes"
assert_not_contains "add_list firewall.tszone.network='tailscale'" "$body" "firewall zone should not depend on a LuCI network interface"
mkdir -p "$TMP_DIR/bin" "$TMP_DIR/state"
cat >"$TMP_DIR/uci_db" <<'EOF'
firewall.@defaults[0].forward=ACCEPT
firewall.@forwarding[0]=forwarding
firewall.@forwarding[0].src=lan
firewall.@forwarding[0].dest=wan
dhcp.@dnsmasq[0]=dnsmasq
dhcp.@dnsmasq[0].address=/example.tailnet/100.100.100.100 /example.tailnet/192.0.2.53
EOF
cat >"$TMP_DIR/bin/uci" <<'SH'
#!/bin/sh
set -eu
db="${UCI_DB:?}"
changes="${UCI_CHANGES_LOG:?}"
quiet=0
if [ "${1:-}" = "-q" ]; then
quiet=1
shift
fi
cmd="${1:-}"
[ -n "$cmd" ] || exit 1
shift || true
get_value() {
key="$1"
awk -F= -v key="$key" '$1 == key { print substr($0, index($0, "=") + 1); found=1; exit } END { if (!found) exit 1 }' "$db"
}
has_key() {
key="$1"
awk -F= -v key="$key" '$1 == key { found=1; exit } END { exit(found ? 0 : 1) }' "$db"
}
set_value() {
key="$1"
value="$2"
tmp="$db.tmp"
awk -F= -v key="$key" -v value="$value" '
BEGIN { replaced=0 }
$1 == key {
print key "=" value
replaced=1
next
}
{ print }
END {
if (!replaced) {
print key "=" value
}
}
' "$db" >"$tmp"
mv "$tmp" "$db"
printf 'set %s=%s\n' "$key" "$value" >>"$changes"
}
delete_value() {
key="$1"
tmp="$db.tmp"
awk -F= -v key="$key" '$1 != key && index($1, key ".") != 1 { print }' "$db" >"$tmp"
mv "$tmp" "$db"
printf 'delete %s\n' "$key" >>"$changes"
}
show_prefix() {
prefix="$1"
awk -F= -v prefix="$prefix" '$1 ~ "^" prefix { print }' "$db"
}
case "$cmd" in
get)
get_value "$1"
;;
set)
key="${1%%=*}"
value="${1#*=}"
set_value "$key" "$value"
;;
add_list)
key="${1%%=*}"
value="${1#*=}"
set_value "$key" "$value"
;;
delete)
[ "${UCI_FAIL_DELETE_KEY:-}" != "$1" ] || exit 1
if has_key "$1"; then
delete_value "$1"
else
[ "$quiet" -eq 1 ] || exit 1
fi
;;
show)
[ "${UCI_FAIL_SHOW_PACKAGE:-}" != "${1:-}" ] || exit 1
show_prefix "${1:-}"
;;
changes)
if [ -s "$changes" ]; then
if [ -n "${1:-}" ]; then
awk -v pkg="$1" '$2 ~ "^" pkg "\\." { print }' "$changes"
else
cat "$changes"
fi
fi
;;
commit)
[ "${UCI_FAIL_COMMIT_PACKAGE:-}" != "${1:-}" ] || exit 1
if [ -n "${1:-}" ]; then
tmp="$changes.tmp"
awk -v pkg="$1" '$2 !~ "^" pkg "\\." { print }' "$changes" >"$tmp"
mv "$tmp" "$changes"
else
: >"$changes"
fi
printf 'commit %s\n' "${1:-}" >>"${UCI_COMMIT_LOG:?}"
;;
revert)
printf 'revert %s\n' "${1:-}" >>"${UCI_REVERT_LOG:?}"
;;
*)
echo "unsupported fake uci command: $cmd" >&2
exit 1
;;
esac
SH
chmod +x "$TMP_DIR/bin/uci"
cat >"$TMP_DIR/bin/flock" <<'SH'
#!/bin/sh
exit 0
SH
chmod +x "$TMP_DIR/bin/flock"
cat >"$TMP_DIR/bin/ifconfig" <<'SH'
#!/bin/sh
printf 'tailscale0 Link encap:Ethernet\n'
SH
chmod +x "$TMP_DIR/bin/ifconfig"
cat >"$TMP_DIR/bin/logger" <<'SH'
#!/bin/sh
printf '%s\n' "$*" >>"${LOGGER_LOG:?}"
SH
chmod +x "$TMP_DIR/bin/logger"
cat >"$TMP_DIR/bin/tailscale" <<'SH'
#!/bin/sh
set -eu
printf '%s\n' "$*" >>"${TAILSCALE_LOG:?}"
case "${1:-}" in
up)
[ "${TAILSCALE_UP_FAIL:-0}" != "1" ] || exit 1
exit 0
;;
ip)
[ "${2:-}" = "-4" ] || exit 1
printf '100.64.0.10\n'
;;
status)
if [ "${TAILSCALE_EMPTY_MAGIC_DNS:-0}" = "1" ]; then
printf '{}\n'
else
printf '{"MagicDNSSuffix":"example.tailnet"}\n'
fi
;;
*)
echo "unsupported fake tailscale command: $*" >&2
exit 1
;;
esac
SH
chmod +x "$TMP_DIR/bin/tailscale"
cat >"$TMP_DIR/firewall-init" <<'SH'
#!/bin/sh
printf '%s\n' "$*" >>"${FIREWALL_LOG:?}"
case "${1:-}" in
reload) [ "${FIREWALL_FAIL_RELOAD:-0}" != "1" ] ;;
restart) [ "${FIREWALL_FAIL_RESTART:-0}" != "1" ] ;;
esac
SH
chmod +x "$TMP_DIR/firewall-init"
cat >"$TMP_DIR/tailscale-init" <<'SH'
#!/bin/sh
printf '%s\n' "$*" >>"${TAILSCALE_INIT_LOG:?}"
SH
chmod +x "$TMP_DIR/tailscale-init"
cat >"$TMP_DIR/dnsmasq-init" <<'SH'
#!/bin/sh
printf '%s\n' "$*" >>"${DNSMASQ_LOG:?}"
SH
chmod +x "$TMP_DIR/dnsmasq-init"
cat >"$TMP_DIR/tailscale-policy-routing" <<'SH'
#!/bin/sh
set -eu
[ "${1:-}" = 'sync' ] || exit 1
printf '%s\n' "$1" >>"${POLICY_ROUTING_LOG:?}"
SH
chmod +x "$TMP_DIR/tailscale-policy-routing"
run_helper() {
exit_node="$1"
allow_wan_direct="${2:-0}"
tailscale_port="${3:-41641}"
wan_direct_zones="${4:-wan}"
access="${5:-}"
disable_snat_subnet_routes="${6:-0}"
accept_dns="${7:-0}"
state_was_present="${8:-1}"
authkey_pending="${9:-0}"
has_authkey="${10:-1}"
ACCESS="$access"
ACCEPT_DNS="$accept_dns"
DISABLE_SNAT_SUBNET_ROUTES="$disable_snat_subnet_routes"
ALLOW_WAN_DIRECT="$allow_wan_direct"
TAILSCALE_PORT="$tailscale_port"
WAN_DIRECT_ZONES="$wan_direct_zones"
UCI_DB="$TMP_DIR/uci_db"
UCI_CHANGES_LOG="$TMP_DIR/uci_changes.log"
UCI_COMMIT_LOG="$TMP_DIR/uci_commit.log"
UCI_REVERT_LOG="$TMP_DIR/uci_revert.log"
TAILSCALE_LOG="$TMP_DIR/tailscale.log"
LOGGER_LOG="$TMP_DIR/logger.log"
FIREWALL_LOG="$TMP_DIR/firewall.log"
TAILSCALE_INIT_LOG="$TMP_DIR/tailscale-init.log"
DNSMASQ_LOG="$TMP_DIR/dnsmasq.log"
POLICY_ROUTING_LOG="$TMP_DIR/policy-routing.log"
PATH="$TMP_DIR/bin:$PATH"
TAILSCALE_BIN="$TMP_DIR/bin/tailscale"
IFCONFIG_BIN="$TMP_DIR/bin/ifconfig"
FLOCK_BIN="$TMP_DIR/bin/flock"
LOCK_FILE="$TMP_DIR/tailscale.lock"
LOGGER_CMD="$TMP_DIR/bin/logger"
FIREWALL_INIT="$TMP_DIR/firewall-init"
TAILSCALE_INIT="$TMP_DIR/tailscale-init"
DNSMASQ_INIT="$TMP_DIR/dnsmasq-init"
POLICY_ROUTING_HELPER="$TMP_DIR/tailscale-policy-routing"
TAILSCALE_SECRETS_BIN="$TMP_DIR/tailscale-secrets"
TAILSCALE_HELPER_STATE_DIR="$TMP_DIR/state"
FIREWALL_PENDING_STATE_FILE="$TMP_DIR/firewall-pending"
TAILSCALE_STATE_WAS_PRESENT="$state_was_present"
SECRET_AUTHKEY_PENDING="$authkey_pending"
SECRET_HAS_AUTHKEY="$has_authkey"
SECRETS_LOG="$TMP_DIR/secrets.log"
export ACCESS ACCEPT_DNS DISABLE_SNAT_SUBNET_ROUTES ALLOW_WAN_DIRECT TAILSCALE_PORT WAN_DIRECT_ZONES UCI_DB UCI_CHANGES_LOG UCI_COMMIT_LOG UCI_REVERT_LOG
export TAILSCALE_LOG LOGGER_LOG FIREWALL_LOG TAILSCALE_INIT_LOG DNSMASQ_LOG POLICY_ROUTING_LOG TAILSCALE_EMPTY_MAGIC_DNS PATH
export TAILSCALE_BIN IFCONFIG_BIN FLOCK_BIN LOCK_FILE LOGGER_CMD FIREWALL_INIT TAILSCALE_INIT DNSMASQ_INIT POLICY_ROUTING_HELPER TAILSCALE_SECRETS_BIN TAILSCALE_HELPER_STATE_DIR FIREWALL_PENDING_STATE_FILE
export TAILSCALE_STATE_WAS_PRESENT SECRET_AUTHKEY_PENDING SECRET_HAS_AUTHKEY SECRETS_LOG
export UCI_FAIL_DELETE_KEY UCI_FAIL_SHOW_PACKAGE UCI_FAIL_COMMIT_PACKAGE FIREWALL_FAIL_RELOAD FIREWALL_FAIL_RESTART TAILSCALE_UP_FAIL
EXIT_NODE="$exit_node" export EXIT_NODE
if ! "$SCRIPT" --run; then
printf 'helper failed for EXIT_NODE=%s\n' "$exit_node" >&2
[ -s "$LOGGER_LOG" ] && { printf 'logger:\n' >&2; cat "$LOGGER_LOG" >&2; }
[ -s "$TAILSCALE_INIT_LOG" ] && { printf 'tailscale-init:\n' >&2; cat "$TAILSCALE_INIT_LOG" >&2; }
[ -s "$UCI_REVERT_LOG" ] && { printf 'uci-revert:\n' >&2; cat "$UCI_REVERT_LOG" >&2; }
return 1
fi
}
cat >"$TMP_DIR/tailscale-secrets" <<'SH'
#!/bin/sh
printf '%s\n' "$*" >>"${SECRETS_LOG:?}"
case "${1:-}" in
get)
[ "${2:-}" = "authkey" ] || exit 1
printf '%s\n' 'tskey-test-only'
;;
authkey-pending)
[ "${SECRET_AUTHKEY_PENDING:-0}" = "1" ]
;;
has)
[ "${2:-}" = "authkey" ] || exit 1
[ "${SECRET_HAS_AUTHKEY:-1}" = "1" ]
;;
clear-authkey)
exit 0
;;
*)
exit 1
;;
esac
SH
chmod +x "$TMP_DIR/tailscale-secrets"
: >"$TMP_DIR/uci_changes.log"
: >"$TMP_DIR/uci_commit.log"
: >"$TMP_DIR/uci_revert.log"
: >"$TMP_DIR/tailscale.log"
: >"$TMP_DIR/logger.log"
: >"$TMP_DIR/firewall.log"
: >"$TMP_DIR/tailscale-init.log"
: >"$TMP_DIR/dnsmasq.log"
: >"$TMP_DIR/policy-routing.log"
: >"$TMP_DIR/secrets.log"
: >"$TMP_DIR/tailscale.log"
: >"$TMP_DIR/secrets.log"
run_helper "" 0 41641 wan "" 0 0 1 0 1
assert_not_contains '--authkey=' "$(cat "$TMP_DIR/tailscale.log")" \
"an existing node must ignore an unmarked auth key left by an older package"
assert_contains 'clear-authkey' "$(cat "$TMP_DIR/secrets.log")" \
"a successful existing-node start must purge the stale stored auth key"
: >"$TMP_DIR/tailscale.log"
: >"$TMP_DIR/secrets.log"
run_helper "" 0 41641 wan "" 0 0 0 0 1
assert_contains '--authkey=tskey-test-only' "$(cat "$TMP_DIR/tailscale.log")" \
"a first enrollment without pre-existing state must use the saved auth key"
assert_contains 'clear-authkey' "$(cat "$TMP_DIR/secrets.log")" \
"a successful first enrollment must purge the auth key"
: >"$TMP_DIR/tailscale.log"
: >"$TMP_DIR/secrets.log"
run_helper "" 0 41641 wan "" 0 0 1 1 1
assert_contains '--authkey=tskey-test-only' "$(cat "$TMP_DIR/tailscale.log")" \
"an explicitly replaced auth key must allow deliberate re-enrollment"
assert_contains 'clear-authkey' "$(cat "$TMP_DIR/secrets.log")" \
"a successful explicit re-enrollment must purge the auth key"
run_helper "peer-exit-node"
assert_contains 'sync' "$(cat "$TMP_DIR/policy-routing.log")" "helper should synchronize policy-routing precedence after Tailscale configuration"
: >"$TMP_DIR/tailscale-init.log"
: >"$TMP_DIR/uci_revert.log"
TAILSCALE_UP_FAIL=1
export TAILSCALE_UP_FAIL
: >"$TMP_DIR/secrets.log"
if run_helper "" 0 41641 wan "" 0 0 0 0 1 >/dev/null 2>&1; then
fail "helper must fail when tailscale up fails"
fi
unset TAILSCALE_UP_FAIL
[ "$(grep -c '^clear-authkey$' "$TMP_DIR/secrets.log" || true)" = "0" ] || \
fail "a failed enrollment must retain its auth key for correction and retry"
[ ! -s "$TMP_DIR/tailscale-init.log" ] || fail "a tailscale up failure must not stop the Tailscale service"
assert_contains "dhcp" "$(cat "$TMP_DIR/uci_revert.log")" "helper failure should revert its pending DHCP changes"
assert_contains "network" "$(cat "$TMP_DIR/uci_revert.log")" "helper failure should revert its pending network changes"
assert_contains "firewall" "$(cat "$TMP_DIR/uci_revert.log")" "helper failure should revert its pending firewall changes"
: >"$TMP_DIR/tailscale.log"
: >"$TMP_DIR/tailscale-init.log"
: >"$TMP_DIR/uci_revert.log"
if "$SCRIPT" --unexpected-mode >/dev/null 2>&1; then
fail "helper must reject an unsupported mode"
fi
[ ! -s "$TMP_DIR/tailscale.log" ] || fail "unsupported helper mode must not call tailscale up"
[ ! -s "$TMP_DIR/tailscale-init.log" ] || fail "unsupported helper mode must not stop the Tailscale service"
[ ! -s "$TMP_DIR/uci_revert.log" ] || fail "unsupported helper mode must not revert unrelated UCI changes"
enabled_forward="$(awk -F= '$1 == "firewall.@defaults[0].forward" { print $2 }' "$TMP_DIR/uci_db")"
[ "$enabled_forward" = "REJECT" ] || fail "exit node enable should force firewall default forward to REJECT
actual: $enabled_forward"
enabled_override="$(awk -F= '$1 == "firewall.@forwarding[0].enabled" { print $2 }' "$TMP_DIR/uci_db")"
[ "$enabled_override" = "0" ] || fail "exit node enable should disable the LAN to WAN forwarding rule
actual: ${enabled_override:-missing}"
[ -f "$TMP_DIR/state/exit_node_firewall_state" ] || fail "exit node enable should persist firewall restore state"
: >"$TMP_DIR/uci_changes.log"
run_helper "" 0 41641 wan "" 0 1
if grep -F 'set dhcp.@dnsmasq[0].address=' "$TMP_DIR/uci_changes.log" >/dev/null; then
fail "MagicDNS setup must not duplicate its address when a dnsmasq list has additional user values"
fi
: >"$TMP_DIR/uci_changes.log"
TAILSCALE_EMPTY_MAGIC_DNS=1 run_helper "" 0 41641 wan "" 0 1
assert_not_contains 'set dhcp.@dnsmasq[0].address=//100.100.100.100' "$(cat "$TMP_DIR/uci_changes.log")" \
"MagicDNS setup must not write an invalid address when the suffix is unavailable"
run_helper ""
restored_forward="$(awk -F= '$1 == "firewall.@defaults[0].forward" { print $2 }' "$TMP_DIR/uci_db")"
[ "$restored_forward" = "ACCEPT" ] || fail "non-exit-node helper run should restore the previous firewall forward policy
actual: $restored_forward"
if awk -F= '$1 == "firewall.@forwarding[0].enabled" { found=1 } END { exit(found ? 0 : 1) }' "$TMP_DIR/uci_db"; then
fail "non-exit-node helper run should remove the temporary LAN to WAN enabled override"
fi
[ ! -f "$TMP_DIR/state/exit_node_firewall_state" ] || fail "helper should clear the exit-node firewall restore state after reconciliation"
assert_contains "reload" "$(cat "$TMP_DIR/firewall.log")" "helper should reload firewall after restoring exit-node changes"
: >"$TMP_DIR/uci_changes.log"
: >"$TMP_DIR/firewall.log"
run_helper "" 1 41641
assert_contains "firewall.ts_wan_direct_1_wan=rule" "$(cat "$TMP_DIR/uci_db")" "WAN direct enable should create a named firewall rule"
assert_contains "firewall.ts_wan_direct_1_wan.name=Allow-Tailscale-WAN-41641-wan" "$(cat "$TMP_DIR/uci_db")" "WAN direct rule should use the configured Tailscale listen port and zone in the rule name"
assert_contains "firewall.ts_wan_direct_1_wan.src=wan" "$(cat "$TMP_DIR/uci_db")" "WAN direct rule should come from the wan zone"
assert_contains "firewall.ts_wan_direct_1_wan.proto=udp" "$(cat "$TMP_DIR/uci_db")" "WAN direct rule should allow UDP"
assert_contains "firewall.ts_wan_direct_1_wan.dest_port=41641" "$(cat "$TMP_DIR/uci_db")" "WAN direct rule should target the configured listen port"
assert_contains "firewall.ts_wan_direct_1_wan.target=ACCEPT" "$(cat "$TMP_DIR/uci_db")" "WAN direct rule should accept matching packets"
assert_contains "reload" "$(cat "$TMP_DIR/firewall.log")" "WAN direct rule changes should reload firewall"
: >"$TMP_DIR/uci_changes.log"
: >"$TMP_DIR/firewall.log"
run_helper "" 1 41641
wan_direct_count="$(awk -F= '$1 ~ /^firewall\.ts_wan_direct[^.]*$/ { count++ } END { print count + 0 }' "$TMP_DIR/uci_db")"
[ "$wan_direct_count" = "1" ] || fail "reapplying WAN direct should keep exactly one named firewall rule
actual: $wan_direct_count"
[ ! -s "$TMP_DIR/uci_changes.log" ] || fail "reapplying an unchanged WAN direct rule should not create UCI changes
actual: $(cat "$TMP_DIR/uci_changes.log")"
[ ! -s "$TMP_DIR/firewall.log" ] || fail "reapplying an unchanged WAN direct rule should not reload firewall
actual: $(cat "$TMP_DIR/firewall.log")"
: >"$TMP_DIR/uci_changes.log"
: >"$TMP_DIR/firewall.log"
run_helper "" 0 41641
if grep -F 'firewall.ts_wan_direct' "$TMP_DIR/uci_db" >/dev/null; then
fail "WAN direct disable should remove the named firewall rule"
fi
: >"$TMP_DIR/uci_changes.log"
: >"$TMP_DIR/firewall.log"
run_helper "" 1 41641 "wan wan2"
assert_contains "firewall.ts_wan_direct_1_wan=rule" "$(cat "$TMP_DIR/uci_db")" "multi-zone WAN direct should create a rule for the wan zone"
assert_contains "firewall.ts_wan_direct_1_wan.name=Allow-Tailscale-WAN-41641-wan" "$(cat "$TMP_DIR/uci_db")" "multi-zone WAN direct rule should include the wan zone in the rule name"
assert_contains "firewall.ts_wan_direct_1_wan.src=wan" "$(cat "$TMP_DIR/uci_db")" "multi-zone WAN direct rule should target the wan zone"
assert_contains "firewall.ts_wan_direct_2_wan2=rule" "$(cat "$TMP_DIR/uci_db")" "multi-zone WAN direct should create a rule for the wan2 zone"
assert_contains "firewall.ts_wan_direct_2_wan2.name=Allow-Tailscale-WAN-41641-wan2" "$(cat "$TMP_DIR/uci_db")" "multi-zone WAN direct rule should include the wan2 zone in the rule name"
assert_contains "firewall.ts_wan_direct_2_wan2.src=wan2" "$(cat "$TMP_DIR/uci_db")" "multi-zone WAN direct rule should target the wan2 zone"
wan_direct_count="$(awk -F= '$1 ~ /^firewall\.ts_wan_direct[^.]*$/ { count++ } END { print count + 0 }' "$TMP_DIR/uci_db")"
[ "$wan_direct_count" = "2" ] || fail "multi-zone WAN direct should create exactly two named firewall rules
actual: $wan_direct_count"
: >"$TMP_DIR/uci_changes.log"
: >"$TMP_DIR/firewall.log"
run_helper "" 1 41641 "wan wan2"
wan_direct_count="$(awk -F= '$1 ~ /^firewall\.ts_wan_direct[^.]*$/ { count++ } END { print count + 0 }' "$TMP_DIR/uci_db")"
[ "$wan_direct_count" = "2" ] || fail "reapplying multi-zone WAN direct should keep exactly two named firewall rules
actual: $wan_direct_count"
[ ! -s "$TMP_DIR/uci_changes.log" ] || fail "reapplying unchanged multi-zone WAN direct rules should not create UCI changes
actual: $(cat "$TMP_DIR/uci_changes.log")"
[ ! -s "$TMP_DIR/firewall.log" ] || fail "reapplying unchanged multi-zone WAN direct rules should not reload firewall
actual: $(cat "$TMP_DIR/firewall.log")"
printf '%s\n' 'firewall.ts_wan_direct_1_wan.enabled=0' >>"$TMP_DIR/uci_db"
: >"$TMP_DIR/uci_changes.log"
: >"$TMP_DIR/firewall.log"
run_helper "" 1 41641 "wan wan2"
if grep -F 'firewall.ts_wan_direct_1_wan.enabled=' "$TMP_DIR/uci_db" >/dev/null; then
fail "WAN direct reconciliation should remove stale managed rule options"
fi
assert_contains "reload" "$(cat "$TMP_DIR/firewall.log")" "removing stale WAN direct options should reload firewall"
: >"$TMP_DIR/uci_changes.log"
: >"$TMP_DIR/firewall.log"
run_helper "" 0 41641 "wan wan2"
if grep -F 'firewall.ts_wan_direct' "$TMP_DIR/uci_db" >/dev/null; then
fail "WAN direct disable should remove every named multi-zone firewall rule"
fi
: >"$TMP_DIR/uci_changes.log"
: >"$TMP_DIR/firewall.log"
run_helper "" 0 41641 "wan wan2"
[ ! -s "$TMP_DIR/uci_changes.log" ] || fail "reapplying disabled WAN direct should not create UCI changes
actual: $(cat "$TMP_DIR/uci_changes.log")"
[ ! -s "$TMP_DIR/firewall.log" ] || fail "reapplying disabled WAN direct should not reload firewall
actual: $(cat "$TMP_DIR/firewall.log")"
: >"$TMP_DIR/uci_changes.log"
: >"$TMP_DIR/firewall.log"
run_helper "" 0 41641 wan "ts_ac_lan lan_ac_ts"
assert_contains "firewall.tszone=zone" "$(cat "$TMP_DIR/uci_db")" "ACCESS should create the Tailscale firewall zone"
assert_contains "firewall.ts_ac_lan=forwarding" "$(cat "$TMP_DIR/uci_db")" "ACCESS should create Tailscale-to-LAN forwarding"
assert_contains "firewall.lan_ac_ts=forwarding" "$(cat "$TMP_DIR/uci_db")" "ACCESS should create LAN-to-Tailscale forwarding"
: >"$TMP_DIR/uci_changes.log"
: >"$TMP_DIR/firewall.log"
run_helper "" 0 41641 wan "ts_ac_lan lan_ac_ts"
[ ! -s "$TMP_DIR/uci_changes.log" ] || fail "reapplying unchanged ACCESS firewall rules should not create UCI changes
actual: $(cat "$TMP_DIR/uci_changes.log")"
[ ! -s "$TMP_DIR/firewall.log" ] || fail "reapplying unchanged ACCESS firewall rules should not reload firewall
actual: $(cat "$TMP_DIR/firewall.log")"
: >"$TMP_DIR/uci_changes.log"
: >"$TMP_DIR/firewall.log"
run_helper "" 0 41641 wan "ts_ac_lan lan_ac_ts" 1
assert_contains "firewall.tszone.masq=0" "$(cat "$TMP_DIR/uci_db")" \
"site-to-site enable must disable masquerade in the app-owned Tailscale firewall zone"
assert_contains "reload" "$(cat "$TMP_DIR/firewall.log")" \
"changing site-to-site SNAT must reload firewall4 through the existing firewall flow"
: >"$TMP_DIR/uci_changes.log"
: >"$TMP_DIR/firewall.log"
run_helper "" 0 41641 wan "ts_ac_lan lan_ac_ts" 1
[ ! -s "$TMP_DIR/uci_changes.log" ] || fail "unchanged no-SNAT firewall state must be idempotent"
[ ! -s "$TMP_DIR/firewall.log" ] || fail "unchanged no-SNAT firewall state must not reload firewall4"
: >"$TMP_DIR/uci_changes.log"
: >"$TMP_DIR/firewall.log"
run_helper "" 0 41641 wan "ts_ac_lan lan_ac_ts" 0
assert_contains "firewall.tszone.masq=1" "$(cat "$TMP_DIR/uci_db")" \
"site-to-site disable must restore masquerade in the app-owned Tailscale firewall zone"
assert_contains "reload" "$(cat "$TMP_DIR/firewall.log")" \
"restoring site-to-site SNAT must reload firewall4 through the existing firewall flow"
: >"$TMP_DIR/uci_changes.log"
: >"$TMP_DIR/firewall.log"
run_helper "" 0 41641 wan "ts_ac_lan lan_ac_ts" 0
[ ! -s "$TMP_DIR/uci_changes.log" ] || fail "reapplying restored SNAT state must not create UCI changes"
[ ! -s "$TMP_DIR/firewall.log" ] || fail "reapplying restored SNAT state must not reload firewall4"
: >"$TMP_DIR/uci_changes.log"
: >"$TMP_DIR/firewall.log"
run_helper "" 0 41641 wan "" 1
empty_access_db="$(cat "$TMP_DIR/uci_db")"
assert_contains "firewall.tszone=zone" "$empty_access_db" \
"site-to-site no-SNAT must keep the Tailscale zone when ACCESS is empty"
assert_contains "firewall.tszone.masq=0" "$empty_access_db" \
"site-to-site no-SNAT must disable masquerade when ACCESS is empty"
for forwarding in ts_ac_lan ts_ac_wan lan_ac_ts wan_ac_ts; do
assert_not_contains "firewall.$forwarding=" "$empty_access_db" \
"empty ACCESS must not create forwarding section $forwarding"
done
: >"$TMP_DIR/uci_changes.log"
: >"$TMP_DIR/firewall.log"
run_helper "" 0 41641 wan "" 1
[ ! -s "$TMP_DIR/uci_changes.log" ] || fail "empty-access no-SNAT state must be idempotent"
[ ! -s "$TMP_DIR/firewall.log" ] || fail "empty-access no-SNAT state must not reload firewall4 when unchanged"
: >"$TMP_DIR/uci_changes.log"
: >"$TMP_DIR/firewall.log"
run_helper "" 0 41641 wan "" 0
empty_access_disabled_db="$(cat "$TMP_DIR/uci_db")"
assert_not_contains "firewall.tszone=" "$empty_access_disabled_db" \
"disabling no-SNAT with empty ACCESS must remove the Tailscale zone"
for forwarding in ts_ac_lan ts_ac_wan lan_ac_ts wan_ac_ts; do
assert_not_contains "firewall.$forwarding=" "$empty_access_disabled_db" \
"disabling no-SNAT with empty ACCESS must not retain forwarding section $forwarding"
done
run_helper "" 0 41641 wan "ts_ac_lan lan_ac_ts"
printf '%s\n' 'firewall.tszone.enabled=0' >>"$TMP_DIR/uci_db"
: >"$TMP_DIR/uci_changes.log"
: >"$TMP_DIR/firewall.log"
run_helper "" 0 41641 wan "ts_ac_lan lan_ac_ts"
if grep -F 'firewall.tszone.enabled=' "$TMP_DIR/uci_db" >/dev/null; then
fail "ACCESS reconciliation should remove stale managed zone options"
fi
: >"$TMP_DIR/uci_changes.log"
: >"$TMP_DIR/firewall.log"
run_helper "" 1 41641
UCI_FAIL_DELETE_KEY=firewall.ts_wan_direct_1_wan
if run_helper "" 0 41641 >/dev/null 2>&1; then
fail "WAN direct cleanup should fail when UCI cannot delete a managed rule"
fi
unset UCI_FAIL_DELETE_KEY
run_helper "" 0 41641
UCI_FAIL_SHOW_PACKAGE=firewall
if run_helper "" 1 41641 >/dev/null 2>&1; then
fail "WAN direct reconciliation should fail when UCI cannot enumerate firewall sections"
fi
unset UCI_FAIL_SHOW_PACKAGE
run_helper "" 0 41641
: >"$TMP_DIR/uci_changes.log"
: >"$TMP_DIR/firewall.log"
run_helper "" 1 41641
: >"$TMP_DIR/tailscale.log"
: >"$TMP_DIR/firewall.log"
ALLOW_WAN_DIRECT=0 "$SCRIPT" --cleanup-wan-direct-firewall
if grep -F 'firewall.ts_wan_direct' "$TMP_DIR/uci_db" >/dev/null; then
fail "standalone WAN direct cleanup should remove managed rules"
fi
[ ! -s "$TMP_DIR/tailscale.log" ] || fail "standalone WAN direct cleanup must not call tailscale up
actual: $(cat "$TMP_DIR/tailscale.log")"
: >"$TMP_DIR/uci_changes.log"
: >"$TMP_DIR/firewall.log"
rm -f "$TMP_DIR/firewall-pending"
FIREWALL_FAIL_RELOAD=1
FIREWALL_FAIL_RESTART=1
if run_helper "" 1 41641 >/dev/null 2>&1; then
fail "helper should fail when firewall reload and restart both fail"
fi
[ -f "$TMP_DIR/firewall-pending" ] || fail "failed firewall apply should leave a pending retry marker"
unset FIREWALL_FAIL_RELOAD FIREWALL_FAIL_RESTART
: >"$TMP_DIR/uci_changes.log"
: >"$TMP_DIR/firewall.log"
run_helper "" 1 41641
assert_contains "reload" "$(cat "$TMP_DIR/firewall.log")" "a pending firewall apply should retry reload even without new UCI changes"
[ ! -e "$TMP_DIR/firewall-pending" ] || fail "successful firewall retry should clear the pending marker"
: >"$TMP_DIR/uci_changes.log"
: >"$TMP_DIR/firewall.log"
run_helper "" 0 41641
: >"$TMP_DIR/firewall-pending"
UCI_FAIL_COMMIT_PACKAGE=firewall
if run_helper "" 1 41641 >/dev/null 2>&1; then
fail "helper should fail when a later firewall commit fails with an existing pending marker"
fi
[ -f "$TMP_DIR/firewall-pending" ] || fail "a pre-existing pending marker must survive a later UCI commit failure"
unset UCI_FAIL_COMMIT_PACKAGE
: >"$TMP_DIR/uci_changes.log"
: >"$TMP_DIR/firewall.log"
run_helper "" 1 41641
assert_contains "reload" "$(cat "$TMP_DIR/firewall.log")" "a preserved pending marker should retry firewall apply after commit recovers"
[ ! -e "$TMP_DIR/firewall-pending" ] || fail "successful retry should clear the preserved pending marker"
: >"$TMP_DIR/uci_changes.log"
: >"$TMP_DIR/firewall.log"
run_helper "" 0 41641
UCI_FAIL_COMMIT_PACKAGE=firewall
if run_helper "" 1 41641 >/dev/null 2>&1; then
fail "helper should fail when the firewall UCI commit fails"
fi
[ ! -e "$TMP_DIR/firewall-pending" ] || fail "failed UCI commit should not leave an apply-only retry marker"
unset UCI_FAIL_COMMIT_PACKAGE
run_helper "" 0 41641
run_helper "peer-exit-node"
[ -f "$TMP_DIR/state/exit_node_firewall_state" ] || fail "exit-node durability test requires persisted restore state"
UCI_FAIL_COMMIT_PACKAGE=firewall
export UCI_FAIL_COMMIT_PACKAGE
if "$SCRIPT" --cleanup-exit-node-firewall >/dev/null 2>&1; then
fail "exit-node cleanup should fail when its firewall commit fails"
fi
[ -f "$TMP_DIR/state/exit_node_firewall_state" ] || fail "exit-node restore state must survive a failed UCI commit"
unset UCI_FAIL_COMMIT_PACKAGE
: >"$TMP_DIR/firewall.log"
"$SCRIPT" --cleanup-exit-node-firewall
[ ! -f "$TMP_DIR/state/exit_node_firewall_state" ] || fail "successful retry should remove exit-node restore state after commit and reload"
assert_contains "reload" "$(cat "$TMP_DIR/firewall.log")" "successful commit retry should reload the firewall"
run_helper "peer-exit-node"
[ -f "$TMP_DIR/state/exit_node_firewall_state" ] || fail "reload durability test requires persisted restore state"
FIREWALL_FAIL_RELOAD=1
FIREWALL_FAIL_RESTART=1
export FIREWALL_FAIL_RELOAD FIREWALL_FAIL_RESTART
if "$SCRIPT" --cleanup-exit-node-firewall >/dev/null 2>&1; then
fail "exit-node cleanup should fail when firewall reload and restart fail"
fi
[ -f "$TMP_DIR/state/exit_node_firewall_state" ] || fail "exit-node restore state must survive failed firewall apply"
[ -f "$TMP_DIR/firewall-pending" ] || fail "failed exit-node firewall apply should preserve the pending retry marker"
unset FIREWALL_FAIL_RELOAD FIREWALL_FAIL_RESTART
: >"$TMP_DIR/uci_changes.log"
: >"$TMP_DIR/firewall.log"
"$SCRIPT" --cleanup-exit-node-firewall
[ ! -f "$TMP_DIR/state/exit_node_firewall_state" ] || fail "pending-only successful retry should remove exit-node restore state"
[ ! -f "$TMP_DIR/firewall-pending" ] || fail "pending-only successful retry should clear the firewall marker"
assert_contains "reload" "$(cat "$TMP_DIR/firewall.log")" "exit-node cleanup should retry an existing pending reload without a new UCI delta"
run_helper "peer-exit-node" 1 41641 wan "ts_ac_lan ts_ac_wan lan_ac_ts wan_ac_ts"
cat >>"$TMP_DIR/uci_db" <<'EOF'
firewall.user_rule=rule
firewall.user_rule.name=User-owned rule
firewall.user_rule.target=ACCEPT
EOF
: >"$TMP_DIR/uci_changes.log"
: >"$TMP_DIR/firewall.log"
"$SCRIPT" --cleanup-managed-firewall
managed_cleanup_db="$(cat "$TMP_DIR/uci_db")"
for managed_section in tszone ts_ac_lan ts_ac_wan lan_ac_ts wan_ac_ts ts_wan_direct_1_wan; do
assert_not_contains "firewall.$managed_section=" "$managed_cleanup_db" "managed cleanup should remove package-owned section $managed_section"
done
assert_contains "firewall.user_rule=rule" "$managed_cleanup_db" "managed cleanup must preserve unrelated user firewall sections"
assert_contains "firewall.@defaults[0].forward=ACCEPT" "$managed_cleanup_db" "managed cleanup should restore stale exit-node firewall state"
[ ! -f "$TMP_DIR/state/exit_node_firewall_state" ] || fail "managed cleanup should clear successfully restored exit-node state"
assert_contains "reload" "$(cat "$TMP_DIR/firewall.log")" "managed cleanup should apply firewall changes"
: >"$TMP_DIR/uci_changes.log"
: >"$TMP_DIR/firewall.log"
"$SCRIPT" --cleanup-managed-firewall
[ ! -s "$TMP_DIR/uci_changes.log" ] || fail "managed firewall cleanup should be idempotent
actual: $(cat "$TMP_DIR/uci_changes.log")"
[ ! -s "$TMP_DIR/firewall.log" ] || fail "idempotent managed firewall cleanup should not reload without changes or a pending marker
actual: $(cat "$TMP_DIR/firewall.log")"
echo "tailscale_helper network cleanup tests passed"
@@ -1,476 +0,0 @@
#!/bin/sh
set -eu
ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
INIT_SCRIPT="$ROOT_DIR/root/etc/init.d/tailscale"
TMP_DIR="${TMPDIR:-/tmp}/tailscale-init-adguard-test.$$"
cleanup() {
rm -rf "$TMP_DIR"
}
trap cleanup EXIT
fail() {
echo "FAIL: $*" >&2
exit 1
}
mkdir -p "$TMP_DIR"
cat >"$TMP_DIR/fake-adguard" <<'SH'
#!/bin/sh
printf '%s\n' "$*" >>"${FAKE_ADGUARD_LOG:?}"
[ "${FAKE_ADGUARD_FAIL:-0}" = "0" ]
SH
chmod +x "$TMP_DIR/fake-adguard"
cat >"$TMP_DIR/fake-helper" <<'SH'
#!/bin/sh
printf '%s\n' "$*" >>"${FAKE_HELPER_LOG:?}"
[ -z "${FAKE_HELPER_FAIL_ARG:-}" ] || [ "$*" != "$FAKE_HELPER_FAIL_ARG" ] || exit 1
SH
chmod +x "$TMP_DIR/fake-helper"
cat >"$TMP_DIR/fake-secrets" <<'SH'
#!/bin/sh
printf '%s\n' "$*" >>"${FAKE_SECRETS_LOG:?}"
SH
chmod +x "$TMP_DIR/fake-secrets"
run_apply_down() {
switch_enabled="$1"
state_profile="${2:-}"
fail_mode="${3:-0}"
FAKE_ADGUARD_LOG="$TMP_DIR/adguard.log"
ADGUARD_DNS_STATE_DIR="$TMP_DIR/state"
: >"$FAKE_ADGUARD_LOG"
rm -rf "$ADGUARD_DNS_STATE_DIR"
if [ -n "$state_profile" ]; then
mkdir -p "$ADGUARD_DNS_STATE_DIR"
printf '%s\n' "$state_profile" >"$ADGUARD_DNS_STATE_DIR/current_profile"
fi
FAKE_ADGUARD_FAIL="$fail_mode"
export FAKE_ADGUARD_LOG FAKE_ADGUARD_FAIL ADGUARD_DNS_STATE_DIR
rc=0
(
. "$INIT_SCRIPT"
PROGA="$TMP_DIR/fake-adguard"
ADGUARD_DNS_STATE_DIR="$ADGUARD_DNS_STATE_DIR"
config_get_bool() {
case "$3" in
adguard_dns_switch_enabled) eval "$1=$switch_enabled" ;;
*) eval "$1=${4:-0}" ;;
esac
}
config_get() {
case "$3" in
adguard_default_upstreams) eval "$1=9.9.9.9" ;;
*) eval "$1=" ;;
esac
}
tailscale_adguard_dns_apply_down settings
) || rc=$?
cat "$FAKE_ADGUARD_LOG"
return "$rc"
}
run_reload_service() {
start_result="${1:-0}"
RELOAD_LOG="$TMP_DIR/reload.log"
: >"$RELOAD_LOG"
export RELOAD_LOG
(
. "$INIT_SCRIPT"
stop() {
printf 'stop:%s\n' "${TAILSCALE_INTERNAL_RELOAD:-0}" >>"$RELOAD_LOG"
}
start() {
printf 'start\n' >>"$RELOAD_LOG"
return "$start_result"
}
reload_service
)
}
run_disabled_start() {
FAKE_HELPER_LOG="$TMP_DIR/disabled-helper.log"
FAKE_SECRETS_LOG="$TMP_DIR/disabled-secrets.log"
CONFIG_DIR="$TMP_DIR/disabled-config"
: >"$FAKE_HELPER_LOG"
: >"$FAKE_SECRETS_LOG"
mkdir -p "$CONFIG_DIR"
export FAKE_HELPER_LOG FAKE_SECRETS_LOG CONFIG_DIR
(
. "$INIT_SCRIPT"
PROG="$TMP_DIR/fake-helper"
PROGS="$TMP_DIR/fake-secrets"
CONFIG_PATH="$CONFIG_DIR"
config_get_bool() {
eval "$1=0"
}
config_get() {
case "$3" in
secrets_ref) eval "$1=disabled-secret-ref" ;;
*) eval "$1=" ;;
esac
}
start_instance settings >/dev/null 2>&1 || true
)
cat "$FAKE_HELPER_LOG"
}
run_stop_instance() {
helper_fail_arg="${1:-}"
reload_mode="${2:-0}"
FAKE_ADGUARD_LOG="$TMP_DIR/stop-adguard.log"
FAKE_HELPER_LOG="$TMP_DIR/helper.log"
ADGUARD_DNS_STATE_DIR="$TMP_DIR/stop-state"
CONFIG_DIR="$TMP_DIR/config"
UCI_CHANGES_OUT="$TMP_DIR/uci-changes.out"
UCI_DEL_LIST_LOG="$TMP_DIR/uci-del-list.log"
UCI_DNSMASQ_ADDRESS_STATE="$TMP_DIR/dnsmasq-addresses"
TAILSCALE_STATUS_OUT="$TMP_DIR/tailscale-status.out"
TAILSCALED_CLEANUP_LOG="$TMP_DIR/tailscaled-cleanup.log"
rm -rf "$ADGUARD_DNS_STATE_DIR" "$CONFIG_DIR"
: >"$FAKE_ADGUARD_LOG"
: >"$FAKE_HELPER_LOG"
: >"$UCI_CHANGES_OUT"
: >"$UCI_DEL_LIST_LOG"
printf '%s\n' '/example.tailnet/100.100.100.100' '/example.tailnet/192.0.2.53' >"$UCI_DNSMASQ_ADDRESS_STATE"
: >"$TAILSCALE_STATUS_OUT"
: >"$TAILSCALED_CLEANUP_LOG"
mkdir -p "$ADGUARD_DNS_STATE_DIR" "$CONFIG_DIR"
printf 'up\n' >"$ADGUARD_DNS_STATE_DIR/current_profile"
printf 'recovery-state\n' >"$CONFIG_DIR/exit_node_firewall_state"
FAKE_HELPER_FAIL_ARG="$helper_fail_arg"
FAKE_ADGUARD_FAIL=0
export FAKE_ADGUARD_LOG FAKE_ADGUARD_FAIL FAKE_HELPER_LOG FAKE_HELPER_FAIL_ARG ADGUARD_DNS_STATE_DIR CONFIG_DIR UCI_CHANGES_OUT UCI_DEL_LIST_LOG UCI_DNSMASQ_ADDRESS_STATE TAILSCALE_STATUS_OUT TAILSCALED_CLEANUP_LOG
(
. "$INIT_SCRIPT"
PROGA="$TMP_DIR/fake-adguard"
PROG="$TMP_DIR/fake-helper"
PROGD="$TMP_DIR/fake-tailscaled"
CONFIG_PATH="$CONFIG_DIR"
ADGUARD_DNS_STATE_DIR="$ADGUARD_DNS_STATE_DIR"
TAILSCALE_INTERNAL_RELOAD="$reload_mode"
config_get_bool() {
case "$3" in
adguard_dns_switch_enabled) eval "$1=0" ;;
*) eval "$1=${4:-0}" ;;
esac
}
config_get() {
case "$3" in
adguard_default_upstreams) eval "$1=9.9.9.9" ;;
*) eval "$1=" ;;
esac
}
tailscale() {
case "$1" in
status)
printf '{"MagicDNSSuffix":"example.tailnet"}\n' >"$TAILSCALE_STATUS_OUT"
printf '{"MagicDNSSuffix":"example.tailnet"}\n'
;;
*)
return 1
;;
esac
}
uci() {
case "$1" in
show)
if [ "${2:-}" = 'dhcp' ]; then
printf '%s\n' 'dhcp.@dnsmasq[0]=dnsmasq'
fi
;;
changes)
cat "$UCI_CHANGES_OUT"
;;
commit)
:
;;
-q)
shift
case "$1" in
del_list)
printf '%s\n' "${2:-}" >>"$UCI_DEL_LIST_LOG"
[ "${2:-}" = 'dhcp.@dnsmasq[0].address=/example.tailnet/100.100.100.100' ]
grep -vxF '/example.tailnet/100.100.100.100' "$UCI_DNSMASQ_ADDRESS_STATE" >"$UCI_DNSMASQ_ADDRESS_STATE.tmp"
mv "$UCI_DNSMASQ_ADDRESS_STATE.tmp" "$UCI_DNSMASQ_ADDRESS_STATE"
;;
*)
return 1
;;
esac
;;
*)
return 1
;;
esac
}
rm() {
command rm "$@"
}
stop_instance settings
)
}
run_service_stopped() {
FAKE_ADGUARD_LOG="$TMP_DIR/service-stopped-adguard.log"
ADGUARD_DNS_STATE_DIR="$TMP_DIR/service-stopped-state"
: >"$FAKE_ADGUARD_LOG"
rm -rf "$ADGUARD_DNS_STATE_DIR"
mkdir -p "$ADGUARD_DNS_STATE_DIR"
printf 'up\n' >"$ADGUARD_DNS_STATE_DIR/current_profile"
FAKE_ADGUARD_FAIL=0
export FAKE_ADGUARD_LOG FAKE_ADGUARD_FAIL ADGUARD_DNS_STATE_DIR
(
. "$INIT_SCRIPT"
PROGA="$TMP_DIR/fake-adguard"
ADGUARD_DNS_STATE_DIR="$ADGUARD_DNS_STATE_DIR"
config_load() { :; }
config_foreach() {
callback="$1"
"$callback" settings
}
config_get_bool() {
case "$3" in
adguard_dns_switch_enabled) eval "$1=0" ;;
*) eval "$1=${4:-0}" ;;
esac
}
config_get() {
case "$3" in
adguard_default_upstreams) eval "$1=9.9.9.9" ;;
*) eval "$1=" ;;
esac
}
command -v service_stopped >/dev/null 2>&1 || fail "init script must define service_stopped"
service_stopped
)
cat "$FAKE_ADGUARD_LOG"
}
capture_tailscale_snat_args() {
disable_snat="$1"
state_was_present="${2:-1}"
PROCD_LOG="$TMP_DIR/procd-snat-$disable_snat.log"
FAKE_SECRETS_LOG="$TMP_DIR/procd-secrets-$disable_snat.log"
: >"$PROCD_LOG"
: >"$FAKE_SECRETS_LOG"
export PROCD_LOG FAKE_SECRETS_LOG
(
. "$INIT_SCRIPT"
PROGS="$TMP_DIR/fake-secrets"
config_get() {
case "$3" in
port) eval "$1=41641" ;;
access) eval "$1=ts_ac_lan" ;;
*) eval "$1=" ;;
esac
}
config_get_bool() {
case "$3" in
disable_snat_subnet_routes) eval "$1=$disable_snat" ;;
accept_dns) eval "$1=1" ;;
*) eval "$1=0" ;;
esac
}
config_list_foreach() { :; }
procd_open_instance() { :; }
procd_set_param() { printf 'set:%s\n' "$*" >>"$PROCD_LOG"; }
procd_append_param() { printf 'append:%s\n' "$*" >>"$PROCD_LOG"; }
procd_close_instance() { :; }
tailscale_helper settings "$state_was_present"
)
[ ! -s "$FAKE_SECRETS_LOG" ] || fail "init must not retrieve the auth key while building the procd command"
cat "$PROCD_LOG"
}
capture_start_state_flag() {
with_state="$1"
START_CONFIG_DIR="$TMP_DIR/start-config-$with_state"
rm -rf "$START_CONFIG_DIR"
mkdir -p "$START_CONFIG_DIR"
if [ "$with_state" = "1" ]; then
printf '%s\n' 'existing-machine-identity' >"$START_CONFIG_DIR/tailscaled.state"
fi
export START_CONFIG_DIR
(
. "$INIT_SCRIPT"
PROG=true
PROGS=true
PROGD=true
CONFIG_PATH="$START_CONFIG_DIR"
config_get() {
case "$3" in
config_path) eval "$1=\$START_CONFIG_DIR" ;;
port) eval "$1=41641" ;;
*) eval "$1=" ;;
esac
}
config_get_bool() {
case "$3" in
enabled) eval "$1=1" ;;
*) eval "$1=${4:-0}" ;;
esac
}
procd_open_instance() { :; }
procd_set_param() { :; }
procd_append_param() { :; }
procd_close_instance() { :; }
tailscale_helper() { printf '%s\n' "$2"; }
tailscale_keepalive() { :; }
tailscale_adguard_dns() { :; }
start_instance settings
)
}
cat >"$TMP_DIR/fake-tailscaled" <<'SH'
#!/bin/sh
[ "${1:-}" = "--cleanup" ] || exit 1
printf '%s\n' "$*" >>"${TAILSCALED_CLEANUP_LOG:?}"
SH
chmod +x "$TMP_DIR/fake-tailscaled"
disabled_output="$(run_apply_down 0)"
[ -z "$disabled_output" ] || fail "disabled AdGuard DNS switch must not apply down profile
actual: $disabled_output"
enabled_output="$(run_apply_down 1)"
[ "$enabled_output" = "--apply-profile down" ] || fail "enabled AdGuard DNS switch should apply down profile
actual: $enabled_output"
disabled_with_managed_profile_output="$(run_apply_down 0 up)"
[ "$disabled_with_managed_profile_output" = "--apply-profile down" ] || fail "disabling a managed AdGuard DNS switch should restore the down profile
actual: $disabled_with_managed_profile_output"
if run_apply_down 0 up 1 >/dev/null; then
fail "failed AdGuard down-profile restoration must propagate through the stop lifecycle"
fi
run_reload_service 0
[ "$(cat "$TMP_DIR/reload.log")" = "stop:1
start" ] || fail "successful reload must use one internal stop followed by start"
if run_reload_service 1; then
fail "reload must fail when restart fails"
fi
[ "$(cat "$TMP_DIR/reload.log")" = "stop:1
start
stop:0" ] || fail "failed reload start must execute a final stop that removes persistent firewall state
actual: $(cat "$TMP_DIR/reload.log")"
disabled_helper_output="$(run_disabled_start)"
[ "$disabled_helper_output" = "--cleanup-managed-firewall" ] || fail "disabled start should invoke complete managed firewall cleanup
actual: ${disabled_helper_output:-<empty>}"
[ "$(cat "$TMP_DIR/disabled-secrets.log")" = "activate disabled-secret-ref" ] || fail "every start, including a disabled service, must activate the credential version selected by UCI
actual: $(cat "$TMP_DIR/disabled-secrets.log")"
run_stop_instance
[ ! -s "$FAKE_ADGUARD_LOG" ] || fail "stop_instance must not switch AdGuard before procd terminates the watcher
actual: $(cat "$FAKE_ADGUARD_LOG")"
[ "$(cat "$UCI_DEL_LIST_LOG")" = 'dhcp.@dnsmasq[0].address=/example.tailnet/100.100.100.100' ] || fail "stop_instance must remove only the exact MagicDNS dnsmasq address
actual: $(cat "$UCI_DEL_LIST_LOG")"
[ "$(cat "$UCI_DNSMASQ_ADDRESS_STATE")" = '/example.tailnet/192.0.2.53' ] || fail "stop_instance must preserve a user-defined address with the same MagicDNS suffix
actual: $(cat "$UCI_DNSMASQ_ADDRESS_STATE")"
service_stopped_output="$(run_service_stopped)"
[ "$service_stopped_output" = "--apply-profile down" ] || fail "service_stopped should apply the down profile after procd terminates the watcher
actual: ${service_stopped_output:-<empty>}"
helper_output="$(cat "$TMP_DIR/helper.log")"
[ "$helper_output" = "--cleanup-managed-firewall" ] || fail "final stop_instance should remove every managed firewall rule
actual: ${helper_output:-<empty>}"
run_stop_instance "" 1
reload_helper_output="$(cat "$TMP_DIR/helper.log")"
[ "$reload_helper_output" = "--cleanup-exit-node-firewall" ] || fail "reload stop should retain persistent Tailscale zone, forwarding, and WAN-direct rules
actual: ${reload_helper_output:-<empty>}"
[ ! -e "$TMP_DIR/reloading" ] || fail "reload state must not be shared through a stale PID marker file"
if run_stop_instance "--cleanup-managed-firewall"; then
fail "stop_instance should report a managed firewall cleanup failure"
fi
[ -f "$CONFIG_DIR/exit_node_firewall_state" ] || fail "stop_instance must preserve helper recovery state when cleanup fails"
snat_disabled_args="$(capture_tailscale_snat_args 1)"
printf '%s\n' "$snat_disabled_args" | grep -F 'set:command ' | grep -F -- ' --run' >/dev/null || \
fail "Tailscale helper procd command must use the explicit --run mode"
if printf '%s\n' "$snat_disabled_args" | grep -F -- '--authkey=' >/dev/null; then
fail "Tailscale auth key must not be included in the procd command"
fi
printf '%s\n' "$snat_disabled_args" | grep -F -- '--snat-subnet-routes=false' >/dev/null || \
fail "site-to-site enable must pass --snat-subnet-routes=false"
printf '%s\n' "$snat_disabled_args" | grep -F 'DISABLE_SNAT_SUBNET_ROUTES=1' >/dev/null || \
fail "site-to-site enable must pass no-SNAT state to the firewall helper"
printf '%s\n' "$snat_disabled_args" | grep -F 'TAILSCALE_STATE_WAS_PRESENT=1' >/dev/null || \
fail "existing state detection must be passed to the helper without exposing the state file"
fresh_state_args="$(capture_tailscale_snat_args 0 0)"
printf '%s\n' "$fresh_state_args" | grep -F 'TAILSCALE_STATE_WAS_PRESENT=0' >/dev/null || \
fail "first enrollment must tell the helper that no state existed before daemon startup"
[ "$(capture_start_state_flag 1)" = "1" ] || \
fail "start_instance must detect a non-empty identity before starting tailscaled"
[ "$(capture_start_state_flag 0)" = "0" ] || \
fail "start_instance must preserve first-enrollment intent before tailscaled can create state"
snat_enabled_args="$(capture_tailscale_snat_args 0)"
printf '%s\n' "$snat_enabled_args" | grep -F -- '--snat-subnet-routes=true' >/dev/null || \
fail "site-to-site disable must pass --snat-subnet-routes=true"
printf '%s\n' "$snat_enabled_args" | grep -F 'DISABLE_SNAT_SUBNET_ROUTES=0' >/dev/null || \
fail "site-to-site disable must pass SNAT state to the firewall helper"
normalized_default="$(
. "$INIT_SCRIPT"
normalize_tailscale_port ""
)"
[ "$normalized_default" = "41641" ] || fail "empty Tailscale port should normalize to 41641"
normalized_invalid="$(
. "$INIT_SCRIPT"
normalize_tailscale_port "invalid"
)"
[ "$normalized_invalid" = "41641" ] || fail "non-numeric Tailscale port should normalize to 41641"
normalized_out_of_range="$(
. "$INIT_SCRIPT"
normalize_tailscale_port "70000"
)"
[ "$normalized_out_of_range" = "41641" ] || fail "out-of-range Tailscale port should normalize to 41641"
normalized_custom="$(
. "$INIT_SCRIPT"
normalize_tailscale_port "42424"
)"
[ "$normalized_custom" = "42424" ] || fail "valid Tailscale port should be preserved"
echo "tailscale init AdGuard lifecycle tests passed"
@@ -1,214 +0,0 @@
#!/bin/sh
set -eu
ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
SCRIPT="$ROOT_DIR/root/usr/sbin/tailscale_keepalive"
INIT_SCRIPT="$ROOT_DIR/root/etc/init.d/tailscale"
TMP_DIR="${TMPDIR:-/tmp}/tailscale-keepalive-test.$$"
STATUS_FILE="$TMP_DIR/status.json"
cleanup() {
rm -rf "$TMP_DIR"
}
trap cleanup EXIT
mkdir -p "$TMP_DIR"
cat >"$TMP_DIR/jshn.sh" <<'SH'
json_cleanup() {
JSHN_LEVEL=
JSHN_PEER=
}
json_load() {
JSHN_JSON="$1"
printf 'load\n' >>"${JSHN_TRACE:?}"
}
json_select() {
printf 'select %s\n' "$1" >>"${JSHN_TRACE:?}"
case "$1:$JSHN_LEVEL" in
Peer:)
JSHN_LEVEL=Peer
;;
TailscaleIPs:peer)
JSHN_LEVEL=ips
;;
..:ips)
JSHN_LEVEL=peer
;;
..:peer)
JSHN_LEVEL=Peer
JSHN_PEER=
;;
*:Peer)
JSHN_PEER="$1"
JSHN_LEVEL=peer
;;
*)
return 1
;;
esac
}
json_get_keys() {
values="$(printf '%s' "$JSHN_JSON" | "${REAL_JQ:?}" -r '.Peer | keys[]')"
eval "$1=\$values"
printf 'get_keys\n' >>"${JSHN_TRACE:?}"
}
json_get_var() {
value="$(printf '%s' "$JSHN_JSON" | "${REAL_JQ:?}" -r --arg peer "$JSHN_PEER" --arg field "$2" '.Peer[$peer][$field] // ""')"
eval "$1=\$value"
printf 'get_var %s\n' "$2" >>"${JSHN_TRACE:?}"
}
json_get_values() {
values="$(printf '%s' "$JSHN_JSON" | "${REAL_JQ:?}" -r --arg peer "$JSHN_PEER" '.Peer[$peer].TailscaleIPs[]?')"
eval "$1=\$values"
printf 'get_values\n' >>"${JSHN_TRACE:?}"
}
SH
cat >"$TMP_DIR/python3" <<'SH'
#!/bin/sh
printf 'python fallback invoked\n' >>"${PYTHON_TRACE:?}"
exit 1
SH
chmod +x "$TMP_DIR/python3"
REAL_JQ="${REAL_JQ:-$(command -v jq)}"
JSHN_TRACE="$TMP_DIR/jshn.trace"
PYTHON_TRACE="$TMP_DIR/python.trace"
: >"$JSHN_TRACE"
: >"$PYTHON_TRACE"
export REAL_JQ JSHN_TRACE PYTHON_TRACE
cat >"$STATUS_FILE" <<'JSON'
{
"Peer": {
"nodekey:one": {
"HostName": "site-a-openwrt",
"DNSName": "site-a-openwrt.example.tailnet.",
"TailscaleIPs": [
"100.64.100.2",
"fd7a:115c:a1e0::2"
]
},
"nodekey:two": {
"HostName": "site-b-gateway",
"DNSName": "site-b-gateway.example.tailnet.",
"TailscaleIPs": [
"100.64.100.1"
]
},
"nodekey:three": {
"HostName": "no-ip-peer",
"DNSName": "no-ip-peer.example.ts.net.",
"TailscaleIPs": []
},
"nodekey:four": {
"HostName": "",
"DNSName": "",
"TailscaleIPs": [
"100.64.100.44"
]
}
}
}
JSON
output="$(PATH="$TMP_DIR:$PATH" JSHN_LIB="$TMP_DIR/jshn.sh" TAILSCALE_STATUS_FILE="$STATUS_FILE" "$SCRIPT" --resolve-peers \
site-a-openwrt \
site-b-gateway.example.tailnet \
100.64.100.2 \
100.64.100.44 \
missing-peer \
no-ip-peer)"
expected="$(printf '%s\t%s\t%s\n%s\t%s\t%s\n%s\t%s\t%s\n%s\t%s\t%s\n%s\t%s\t%s\n%s\t%s\t%s' \
site-a-openwrt OK 100.64.100.2 \
site-b-gateway.example.tailnet OK 100.64.100.1 \
100.64.100.2 OK 100.64.100.2 \
100.64.100.44 OK 100.64.100.44 \
missing-peer NOT_FOUND '' \
no-ip-peer NO_IP '')"
if [ "$output" != "$expected" ]; then
printf 'unexpected resolver output\nexpected:\n%s\nactual:\n%s\n' "$expected" "$output" >&2
exit 1
fi
grep -F 'select Peer' "$JSHN_TRACE" >/dev/null || {
printf 'expected production jshn resolver path to select Peer\n' >&2
exit 1
}
grep -F 'get_values' "$JSHN_TRACE" >/dev/null || {
printf 'expected production jshn resolver path to read TailscaleIPs\n' >&2
exit 1
}
[ ! -s "$PYTHON_TRACE" ] || {
printf 'jshn resolver unexpectedly fell back to Python:\n' >&2
cat "$PYTHON_TRACE" >&2
exit 1
}
rm -f "$TMP_DIR/python3"
cat >"$STATUS_FILE" <<'JSON'
{"Peer":{"nodekey:one":{"TailscaleIPs":["100.64.100.9"],"DNSName":"compact-peer.example.tailnet.","HostName":"compact-peer"},"nodekey:two":{"DNSName":"reordered.example.tailnet.","HostName":"reordered-peer","Online":true,"TailscaleIPs":["100.64.100.10"]}}}
JSON
output="$(TAILSCALE_STATUS_FILE="$STATUS_FILE" "$SCRIPT" --resolve-peers \
compact-peer \
reordered.example.tailnet)"
expected="$(printf '%s\t%s\t%s\n%s\t%s\t%s' \
compact-peer OK 100.64.100.9 \
reordered.example.tailnet OK 100.64.100.10)"
if [ "$output" != "$expected" ]; then
printf 'unexpected compact resolver output\nexpected:\n%s\nactual:\n%s\n' "$expected" "$output" >&2
exit 1
fi
cat >"$TMP_DIR/logger" <<'SH'
#!/bin/sh
printf '%s\n' "$*" >>"$LOGGER_OUTPUT"
SH
chmod +x "$TMP_DIR/logger"
LOGGER_OUTPUT="$TMP_DIR/logger.out"
PATH="$TMP_DIR:$PATH"
export LOGGER_OUTPUT PATH
(
config_get_bool() {
eval "$1=1"
}
config_get() {
case "$3" in
keepalive_peers) eval "$1=" ;;
keepalive_interval) eval "$1=20" ;;
keepalive_failure_log_interval) eval "$1=300" ;;
log_stdout|log_stderr) eval "$1=0" ;;
*) eval "$1=" ;;
esac
}
procd_open_instance() { :; }
procd_set_param() { :; }
procd_append_param() { :; }
procd_close_instance() { :; }
. "$INIT_SCRIPT"
tailscale_keepalive cfg
)
if ! grep -q 'keepalive enabled but no peers configured' "$LOGGER_OUTPUT"; then
printf 'expected init script to log empty keepalive peers\n' >&2
exit 1
fi
printf 'tailscale_keepalive resolver tests passed\n'
@@ -1,85 +0,0 @@
#!/bin/sh
set -eu
ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
SCRIPT="$ROOT_DIR/root/etc/uci-defaults/40_luci-tailscale"
TMP_DIR="${TMPDIR:-/tmp}/tailscale-legacy-adguard-migration-test.$$"
cleanup() {
rm -rf "$TMP_DIR"
}
trap cleanup EXIT
fail() {
echo "FAIL: $*" >&2
exit 1
}
mkdir -p "$TMP_DIR/init.d" "$TMP_DIR/rc.d" "$TMP_DIR/bin"
cat >"$TMP_DIR/init.d/tailscale-adguard-dns" <<'SH'
#!/bin/sh
printf '%s\n' "$*" >>"${LEGACY_SERVICE_LOG:?}"
if [ "${1:-}" = 'disable' ]; then
rm -f "${LEGACY_ADGUARD_DNS_RC_DIR:?}"/*tailscale-adguard-dns
fi
SH
chmod +x "$TMP_DIR/init.d/tailscale-adguard-dns"
ln -s "$TMP_DIR/init.d/tailscale-adguard-dns" "$TMP_DIR/rc.d/S99tailscale-adguard-dns"
cat >"$TMP_DIR/bin/tailscale-secrets" <<'SH'
#!/bin/sh
printf '%s\n' "$*" >>"${SECRETS_LOG:?}"
[ "${1:-}" = 'migrate' ]
SH
chmod +x "$TMP_DIR/bin/tailscale-secrets"
cat >"$TMP_DIR/bin/uci" <<'SH'
#!/bin/sh
case "${1:-}" in
-q) exit 1 ;;
*) exit 1 ;;
esac
SH
chmod +x "$TMP_DIR/bin/uci"
LEGACY_SERVICE_LOG="$TMP_DIR/legacy-service.log"
SECRETS_LOG="$TMP_DIR/secrets.log"
: >"$LEGACY_SERVICE_LOG"
: >"$SECRETS_LOG"
export LEGACY_SERVICE_LOG SECRETS_LOG
run_migration() {
UPGRADE_STATE_DIR="$TMP_DIR/upgrade" \
TAILSCALE_SECRETS_BIN="$TMP_DIR/bin/tailscale-secrets" \
UCI_BIN="$TMP_DIR/bin/uci" \
OPENCLASH_BYPASS_INIT="$TMP_DIR/init.d/tailscale-openclash-bypass" \
LEGACY_ADGUARD_DNS_INIT="$TMP_DIR/init.d/tailscale-adguard-dns" \
LEGACY_ADGUARD_DNS_RC_DIR="$TMP_DIR/rc.d" \
LEGACY_ADGUARD_DNS_BACKUP_DIR="$TMP_DIR/backups" \
LUCI_INDEX_CACHE="$TMP_DIR/luci-indexcache" \
"$SCRIPT"
}
run_migration
[ ! -e "$TMP_DIR/init.d/tailscale-adguard-dns" ] || fail "legacy AdGuard DNS init script must be removed from init.d"
[ ! -e "$TMP_DIR/rc.d/S99tailscale-adguard-dns" ] || fail "legacy AdGuard DNS init link must be removed"
expected_legacy_log="$(printf 'stop\ndisable')"
[ "$(cat "$LEGACY_SERVICE_LOG")" = "$expected_legacy_log" ] || fail "legacy AdGuard DNS service must be stopped and disabled before migration"
[ "$(cat "$SECRETS_LOG")" = 'migrate' ] || fail "credential migration must still run after legacy service cleanup"
backup_dir="$(find "$TMP_DIR/backups" -mindepth 1 -maxdepth 1 -type d | head -n 1)"
[ -n "$backup_dir" ] || fail "legacy AdGuard DNS service must be backed up before removal"
[ -f "$backup_dir/init" ] || fail "legacy AdGuard DNS init script backup is missing"
[ -f "$backup_dir/tailscale-adguard-dns.disabled" ] || fail "disabled legacy AdGuard DNS init script backup is missing"
expected_rc_link="$(printf 'S99tailscale-adguard-dns\t%s' "$TMP_DIR/init.d/tailscale-adguard-dns")"
[ "$(cat "$backup_dir/rc-links")" = "$expected_rc_link" ] || fail "legacy rc link backup is incomplete"
run_migration
[ "$(cat "$LEGACY_SERVICE_LOG")" = "$expected_legacy_log" ] || fail "legacy service cleanup must be idempotent"
expected_secrets_log="$(printf 'migrate\nmigrate')"
[ "$(cat "$SECRETS_LOG")" = "$expected_secrets_log" ] || fail "credential migration must continue on an idempotent upgrade run"
echo "tailscale legacy AdGuard migration tests passed"
File diff suppressed because it is too large. Load diff
@@ -1,348 +0,0 @@
#!/bin/sh
set -eu
ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
UCI_DEFAULTS="$ROOT_DIR/root/etc/uci-defaults/40_luci-tailscale"
TMP_DIR="${TMPDIR:-/tmp}/tailscale-openclash-install-test.$$"
CONFIG_DIR="$TMP_DIR/etc/config"
UPGRADE_STATE_DIR="$TMP_DIR/luci-app-tailscale-upgrade"
LEGACY_TAILSCALE_HOTPLUG="$TMP_DIR/40-tailscale"
LEGACY_TAILSCALE_HOTPLUG_BACKUP_DIR="$TMP_DIR/legacy-backup"
cleanup() {
rm -rf "$TMP_DIR"
}
trap cleanup EXIT
fail() {
echo "FAIL: $*" >&2
exit 1
}
[ -f "$UCI_DEFAULTS" ] || fail "missing package UCI defaults script"
mkdir -p "$TMP_DIR/bin" "$CONFIG_DIR"
cat >"$TMP_DIR/tailscale_secrets" <<'SH'
#!/bin/sh
printf 'secrets %s\n' "$*" >>"${LIFECYCLE_LOG:?}"
SH
chmod +x "$TMP_DIR/tailscale_secrets"
cat >"$TMP_DIR/tailscale-openclash-bypass" <<'SH'
#!/bin/sh
printf 'bypass %s\n' "$*" >>"${LIFECYCLE_LOG:?}"
SH
chmod +x "$TMP_DIR/tailscale-openclash-bypass"
cat >"$TMP_DIR/tailscale-policy-routing" <<'SH'
#!/bin/sh
printf 'policy %s\n' "$*" >>"${LIFECYCLE_LOG:?}"
SH
chmod +x "$TMP_DIR/tailscale-policy-routing"
cat >"$TMP_DIR/bin/uci" <<'SH'
#!/bin/sh
case "$*" in
'-q get tailscale.settings.enabled')
printf '%s\n' "${TAILSCALE_ENABLED:-0}"
exit 0
;;
'-q get tailscale_openclash.settings.enabled')
printf '%s\n' "${OPENCLASH_BYPASS_ENABLED:-1}"
exit 0
;;
esac
printf 'uci %s\n' "$*" >>"${LIFECYCLE_LOG:?}"
cat >/dev/null
SH
chmod +x "$TMP_DIR/bin/uci"
cat >"$TMP_DIR/tailscale" <<'SH'
#!/bin/sh
printf 'tailscale %s\n' "$*" >>"${LIFECYCLE_LOG:?}"
SH
chmod +x "$TMP_DIR/tailscale"
run_defaults() {
mode="$1"
: >"$TMP_DIR/lifecycle.log"
if [ "$mode" = "without-bypass" ]; then
rm -f "$TMP_DIR/tailscale-openclash-bypass"
fi
# Run an isolated copy so the package script's production absolute paths use
# the test fixtures without adding test-only hooks to the package itself.
sed \
-e "s#/usr/sbin/tailscale_secrets#$TMP_DIR/tailscale_secrets#g" \
-e "s#/etc/init.d/tailscale-openclash-bypass#$TMP_DIR/tailscale-openclash-bypass#g" \
-e "s#/etc/init.d/tailscale-policy-routing#$TMP_DIR/tailscale-policy-routing#g" \
-e "s#/etc/config/tailscale#$CONFIG_DIR/tailscale#g" \
-e "s#/etc/.luci-app-tailscale-upgrade#$UPGRADE_STATE_DIR#g" \
"$UCI_DEFAULTS" >"$TMP_DIR/uci-defaults"
chmod +x "$TMP_DIR/uci-defaults"
PATH="$TMP_DIR/bin:$PATH" \
LIFECYCLE_LOG="$TMP_DIR/lifecycle.log" \
sh "$TMP_DIR/uci-defaults"
cat "$TMP_DIR/lifecycle.log"
}
enabled_log="$(run_defaults enabled)"
expected_enabled='secrets migrate
bypass enable
bypass start
policy enable
policy start'
[ "$enabled_log" = "$expected_enabled" ] || fail "package defaults must migrate credentials, then enable and start managed helpers\nactual:\n$enabled_log"
cat >"$TMP_DIR/tailscale-openclash-bypass" <<'SH'
#!/bin/sh
printf 'bypass %s\n' "$*" >>"${LIFECYCLE_LOG:?}"
SH
chmod +x "$TMP_DIR/tailscale-openclash-bypass"
absent_log="$(run_defaults without-bypass)"
expected_absent='secrets migrate
policy enable
policy start'
[ "$absent_log" = "$expected_absent" ] || fail "missing bypass init script must not prevent policy-routing initialization\nactual:\n$absent_log"
cat >"$TMP_DIR/tailscale-openclash-bypass" <<'SH'
#!/bin/sh
printf 'bypass %s\n' "$*" >>"${LIFECYCLE_LOG:?}"
SH
chmod +x "$TMP_DIR/tailscale-openclash-bypass"
run_preinst() {
awk '
/^define Package\/luci-app-tailscale\/preinst$/ { in_preinst = 1; next }
in_preinst && /^endef$/ { exit }
in_preinst { print }
' "$ROOT_DIR/Makefile" \
| sed \
-e 's/\$\${/${/g' \
-e "s#/etc/hotplug.d/iface/40-tailscale#$LEGACY_TAILSCALE_HOTPLUG#g" \
-e "s#/etc/tailscale/luci-app-tailscale-legacy#$LEGACY_TAILSCALE_HOTPLUG_BACKUP_DIR#g" \
-e "s#/etc/config/tailscale#$CONFIG_DIR/tailscale#g" \
-e "s#/etc/.luci-app-tailscale-upgrade#$UPGRADE_STATE_DIR#g" \
>"$TMP_DIR/preinst"
[ -s "$TMP_DIR/preinst" ] || fail "missing package pre-install configuration snapshot"
chmod +x "$TMP_DIR/preinst"
PATH="${PREINST_PATH:-$PATH}" IPKG_INSTROOT='' sh "$TMP_DIR/preinst"
}
cat >"$LEGACY_TAILSCALE_HOTPLUG" <<'EOF'
#!/bin/sh
/etc/init.d/tailscale start &
EOF
cp "$LEGACY_TAILSCALE_HOTPLUG" "$TMP_DIR/40-tailscale.before"
cat >"$CONFIG_DIR/tailscale" <<'EOF'
config tailscale 'settings'
option enabled '1'
option hostname 'upgrade-preserved'
EOF
cat >"$CONFIG_DIR/tailscale_openclash" <<'EOF'
config openclash 'settings'
option enabled '1'
EOF
cat >"$CONFIG_DIR/tailscale_policy_routing" <<'EOF'
config settings 'settings'
option enabled '1'
EOF
cp "$CONFIG_DIR/tailscale" "$TMP_DIR/tailscale-before-upgrade"
cp "$CONFIG_DIR/tailscale_openclash" "$TMP_DIR/tailscale-openclash-before-upgrade"
cp "$CONFIG_DIR/tailscale_policy_routing" "$TMP_DIR/tailscale-policy-routing-before-upgrade"
run_preinst
[ ! -e "$LEGACY_TAILSCALE_HOTPLUG" ] || fail "pre-install hook must disable the legacy Tailscale interface hotplug starter before extraction"
legacy_hotplug_backup="$(find "$LEGACY_TAILSCALE_HOTPLUG_BACKUP_DIR" -type f -name '40-tailscale.disabled.*' -print)"
[ -n "$legacy_hotplug_backup" ] || fail "pre-install hook must preserve a backup of the disabled legacy Tailscale hotplug starter"
cmp -s "$TMP_DIR/40-tailscale.before" "$legacy_hotplug_backup" || fail "legacy Tailscale hotplug backup must preserve the original script"
[ -f "$UPGRADE_STATE_DIR/tailscale" ] || fail "pre-install hook must snapshot the existing Tailscale UCI config"
[ -f "$UPGRADE_STATE_DIR/tailscale_openclash" ] || fail "pre-install hook must snapshot the existing OpenClash bypass UCI config"
[ -f "$UPGRADE_STATE_DIR/tailscale_policy_routing" ] || fail "pre-install hook must snapshot the existing policy-routing UCI config"
[ -f "$UPGRADE_STATE_DIR/.complete" ] || fail "pre-install hook must only publish a completed configuration snapshot"
# Backup failure must not leave the duplicate hotplug starter executable.
rm -rf "$LEGACY_TAILSCALE_HOTPLUG_BACKUP_DIR"
printf 'not a directory\n' >"$LEGACY_TAILSCALE_HOTPLUG_BACKUP_DIR"
cp "$TMP_DIR/40-tailscale.before" "$LEGACY_TAILSCALE_HOTPLUG"
run_preinst
[ ! -e "$LEGACY_TAILSCALE_HOTPLUG" ] || fail "pre-install hook must disable the legacy starter even when its backup cannot be created"
rm -f "$LEGACY_TAILSCALE_HOTPLUG_BACKUP_DIR"
# If removing the obsolete path itself fails transiently, preinst must make the
# script non-executable before package extraction continues.
mkdir -p "$TMP_DIR/fail-bin" "$LEGACY_TAILSCALE_HOTPLUG_BACKUP_DIR"
cat >"$TMP_DIR/fail-bin/rm" <<EOF
#!/bin/sh
if [ "\$*" = "-f $LEGACY_TAILSCALE_HOTPLUG" ]; then
exit 1
fi
exec /bin/rm "\$@"
EOF
chmod +x "$TMP_DIR/fail-bin/rm"
cp "$TMP_DIR/40-tailscale.before" "$LEGACY_TAILSCALE_HOTPLUG"
PREINST_PATH="$TMP_DIR/fail-bin:$PATH" run_preinst
[ -e "$LEGACY_TAILSCALE_HOTPLUG" ] || fail "remove-failure fixture must leave the legacy hook path in place"
[ ! -x "$LEGACY_TAILSCALE_HOTPLUG" ] || fail "pre-install hook must make the legacy starter non-executable when removal fails"
chmod 755 "$LEGACY_TAILSCALE_HOTPLUG"
rm -f "$LEGACY_TAILSCALE_HOTPLUG"
# Model package extraction replacing non-conffile files before the new package's
# standard UCI-defaults phase runs.
cat >"$CONFIG_DIR/tailscale" <<'EOF'
config tailscale 'settings'
option enabled '0'
EOF
cat >"$CONFIG_DIR/tailscale_openclash" <<'EOF'
config openclash 'settings'
option enabled '0'
EOF
cat >"$CONFIG_DIR/tailscale_policy_routing" <<'EOF'
config settings 'settings'
option enabled '0'
EOF
# A failed package install can be retried after the new data files have already
# replaced the old config. The second pre-install hook must retain the first
# snapshot instead of replacing it with those defaults.
run_preinst
cmp -s "$TMP_DIR/tailscale-before-upgrade" "$UPGRADE_STATE_DIR/tailscale" || \
fail "retrying an interrupted upgrade must retain the first Tailscale configuration snapshot"
cmp -s "$TMP_DIR/tailscale-openclash-before-upgrade" "$UPGRADE_STATE_DIR/tailscale_openclash" || \
fail "retrying an interrupted upgrade must retain the first OpenClash bypass configuration snapshot"
cmp -s "$TMP_DIR/tailscale-policy-routing-before-upgrade" "$UPGRADE_STATE_DIR/tailscale_policy_routing" || \
fail "retrying an interrupted upgrade must retain the first policy-routing configuration snapshot"
upgrade_defaults_log="$(run_defaults enabled)"
[ "$upgrade_defaults_log" = "$expected_enabled" ] || fail "upgrade defaults must restore configuration before reconciling the bypass\nactual:\n$upgrade_defaults_log"
cmp -s "$TMP_DIR/tailscale-before-upgrade" "$CONFIG_DIR/tailscale" || \
fail "upgrade must preserve the existing Tailscale UCI configuration"
cmp -s "$TMP_DIR/tailscale-openclash-before-upgrade" "$CONFIG_DIR/tailscale_openclash" || \
fail "upgrade must preserve the existing OpenClash bypass UCI configuration"
cmp -s "$TMP_DIR/tailscale-policy-routing-before-upgrade" "$CONFIG_DIR/tailscale_policy_routing" || \
fail "upgrade must preserve the existing policy-routing UCI configuration"
[ ! -e "$UPGRADE_STATE_DIR/tailscale" ] || fail "successful configuration restore must remove the Tailscale snapshot"
[ ! -e "$UPGRADE_STATE_DIR/tailscale_openclash" ] || fail "successful configuration restore must remove the OpenClash bypass snapshot"
[ ! -e "$UPGRADE_STATE_DIR/tailscale_policy_routing" ] || fail "successful configuration restore must remove the policy-routing snapshot"
[ ! -d "$UPGRADE_STATE_DIR" ] || fail "successful configuration restore must remove the private snapshot directory"
: >"$TMP_DIR/lifecycle.log"
LIFECYCLE_LOG="$TMP_DIR/lifecycle.log" "$TMP_DIR/tailscale-openclash-bypass" start
LIFECYCLE_LOG="$TMP_DIR/lifecycle.log" "$TMP_DIR/tailscale" start
upgrade_restart_log="$(cat "$TMP_DIR/lifecycle.log")"
expected_restart_log='bypass start
tailscale start'
[ "$upgrade_restart_log" = "$expected_restart_log" ] || fail "standard package restart must bring the bypass and enabled Tailscale service back after upgrade\nactual:\n$upgrade_restart_log"
disabled_bypass_log="$(OPENCLASH_BYPASS_ENABLED=0 run_defaults enabled)"
[ "$disabled_bypass_log" = "$expected_absent" ] || fail "package lifecycle must not re-enable a bypass the user disabled\nactual:\n$disabled_bypass_log"
# Model a pre-install interruption after a partial state directory was written.
# The next attempt must discard that partial data and publish a complete snapshot.
mkdir -p "$UPGRADE_STATE_DIR"
printf 'partial configuration\n' >"$UPGRADE_STATE_DIR/tailscale"
run_preinst
cmp -s "$TMP_DIR/tailscale-before-upgrade" "$UPGRADE_STATE_DIR/tailscale" || \
fail "retrying after an interrupted snapshot must replace partial Tailscale data"
cmp -s "$TMP_DIR/tailscale-openclash-before-upgrade" "$UPGRADE_STATE_DIR/tailscale_openclash" || \
fail "retrying after an interrupted snapshot must replace partial OpenClash data"
cmp -s "$TMP_DIR/tailscale-policy-routing-before-upgrade" "$UPGRADE_STATE_DIR/tailscale_policy_routing" || \
fail "retrying after an interrupted snapshot must replace partial policy-routing data"
[ -f "$UPGRADE_STATE_DIR/.complete" ] || fail "retrying after an interrupted snapshot must publish a completion marker"
run_defaults enabled >/dev/null
[ ! -d "$UPGRADE_STATE_DIR" ] || fail "restoring a retried snapshot must remove the private state directory"
run_prerm() {
mode="$1"
style="${2:-ipk}"
awk '
/^define Package\/luci-app-tailscale\/prerm$/ { in_prerm = 1; next }
in_prerm && /^endef$/ { exit }
in_prerm { print }
' "$ROOT_DIR/Makefile" \
| sed \
-e 's/\$\${/${/g' \
-e "s#/usr/sbin/tailscale_openclash_bypass#$TMP_DIR/tailscale_openclash_bypass#g" \
-e "s#/etc/init.d/tailscale-openclash-bypass#$TMP_DIR/tailscale-openclash-bypass#g" \
-e "s#/usr/sbin/tailscale_policy_routing#$TMP_DIR/tailscale_policy_routing_helper#g" \
-e "s#/etc/init.d/tailscale-policy-routing#$TMP_DIR/tailscale-policy-routing#g" \
-e "s#/etc/init.d/tailscale#$TMP_DIR/tailscale#g" \
-e "s#/etc/config/tailscale#$CONFIG_DIR/tailscale#g" \
-e "s#/etc/.luci-app-tailscale-upgrade#$UPGRADE_STATE_DIR#g" \
>"$TMP_DIR/prerm"
chmod +x "$TMP_DIR/prerm"
cat >"$TMP_DIR/tailscale_openclash_bypass" <<'SH'
#!/bin/sh
printf 'helper %s\n' "$*" >>"${LIFECYCLE_LOG:?}"
SH
chmod +x "$TMP_DIR/tailscale_openclash_bypass"
cat >"$TMP_DIR/tailscale-openclash-bypass" <<'SH'
#!/bin/sh
printf 'bypass %s\n' "$*" >>"${LIFECYCLE_LOG:?}"
SH
chmod +x "$TMP_DIR/tailscale-openclash-bypass"
cat >"$TMP_DIR/tailscale_policy_routing_helper" <<'SH'
#!/bin/sh
printf 'policy-helper %s\n' "$*" >>"${LIFECYCLE_LOG:?}"
[ "${POLICY_CLEANUP_FAIL:-0}" = 1 ] && [ "${1:-}" = cleanup ] && exit 1
exit 0
SH
chmod +x "$TMP_DIR/tailscale_policy_routing_helper"
cat >"$TMP_DIR/tailscale-policy-routing" <<'SH'
#!/bin/sh
printf 'policy %s\n' "$*" >>"${LIFECYCLE_LOG:?}"
SH
chmod +x "$TMP_DIR/tailscale-policy-routing"
: >"$TMP_DIR/lifecycle.log"
if [ "$style" = 'apk' ]; then
IPKG_INSTROOT='' LIFECYCLE_LOG="$TMP_DIR/lifecycle.log" POLICY_CLEANUP_FAIL="${POLICY_CLEANUP_FAIL:-0}" sh "$TMP_DIR/prerm" "$mode" || return 1
else
IPKG_INSTROOT='' LIFECYCLE_LOG="$TMP_DIR/lifecycle.log" POLICY_CLEANUP_FAIL="${POLICY_CLEANUP_FAIL:-0}" sh "$TMP_DIR/prerm" "$TMP_DIR/prerm-wrapper" "$mode" || return 1
fi
cat "$TMP_DIR/lifecycle.log"
}
upgrade_prerm_log="$(run_prerm upgrade)"
[ -z "$upgrade_prerm_log" ] || fail "package upgrade must preserve helper rules, bypass startup, and Tailscale\nactual:\n$upgrade_prerm_log"
[ ! -d "$UPGRADE_STATE_DIR" ] || fail "package upgrade must not leave removal state or replace the pre-install snapshot"
mkdir -p "$UPGRADE_STATE_DIR" "$UPGRADE_STATE_DIR.pending"
: >"$UPGRADE_STATE_DIR/tailscale"
: >"$UPGRADE_STATE_DIR/tailscale_openclash"
: >"$UPGRADE_STATE_DIR/tailscale_openclash.absent"
: >"$UPGRADE_STATE_DIR/tailscale_policy_routing"
: >"$UPGRADE_STATE_DIR/.complete"
: >"$UPGRADE_STATE_DIR.pending/tailscale"
: >"$UPGRADE_STATE_DIR.pending/tailscale_openclash"
: >"$UPGRADE_STATE_DIR.pending/tailscale_openclash.absent"
: >"$UPGRADE_STATE_DIR.pending/tailscale_policy_routing"
: >"$UPGRADE_STATE_DIR.pending/.complete"
prerm_log="$(run_prerm remove)"
expected_prerm='policy-helper cleanup
policy disable
helper cleanup
bypass disable
tailscale stop'
[ "$prerm_log" = "$expected_prerm" ] || fail "package removal must clean helper rules, disable bypass boot startup, then stop Tailscale\nactual:\n$prerm_log"
[ ! -d "$UPGRADE_STATE_DIR" ] || fail "package removal must remove completed upgrade state"
[ ! -d "$UPGRADE_STATE_DIR.pending" ] || fail "package removal must remove pending upgrade state"
apk_prerm_log="$(run_prerm 1.2.10 apk)"
[ "$apk_prerm_log" = "$expected_prerm" ] || fail "OpenWrt APK pre-deinstall must perform package removal cleanup\nactual:\n$apk_prerm_log"
if POLICY_CLEANUP_FAIL=1 run_prerm remove >/dev/null; then
fail 'package removal must stop when policy-routing cleanup fails'
fi
[ "$(cat "$TMP_DIR/lifecycle.log")" = 'policy-helper cleanup' ] || \
fail 'failed policy-routing cleanup must not disable services or stop Tailscale'
echo "tailscale OpenClash install lifecycle tests passed"
@@ -1,85 +0,0 @@
#!/bin/sh
set -eu
ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
INIT_SCRIPT="$ROOT_DIR/root/etc/init.d/tailscale-openclash-bypass"
UCI_CONFIG="$ROOT_DIR/root/etc/config/tailscale_openclash"
TMP_DIR="${TMPDIR:-/tmp}/tailscale-openclash-lifecycle-test.$$"
cleanup() {
rm -rf "$TMP_DIR"
}
trap cleanup EXIT
fail() {
echo "FAIL: $*" >&2
exit 1
}
[ -f "$INIT_SCRIPT" ] || fail "missing OpenClash bypass init script"
[ -f "$UCI_CONFIG" ] || fail "missing OpenClash bypass UCI config"
grep -qx "config openclash 'settings'" "$UCI_CONFIG" || \
fail 'OpenClash bypass UCI config must use the openclash section type'
mkdir -p "$TMP_DIR"
cat >"$TMP_DIR/fake-helper" <<'SH'
#!/bin/sh
printf '%s\n' "$*" >>"${FAKE_HELPER_LOG:?}"
SH
chmod +x "$TMP_DIR/fake-helper"
run_service() {
service_entrypoint="$1"
script_path="${2:-$INIT_SCRIPT}"
FAKE_HELPER_LOG="$TMP_DIR/helper.log"
: >"$FAKE_HELPER_LOG"
export FAKE_HELPER_LOG
rc=0
(
. "$script_path"
PROG="$TMP_DIR/fake-helper"
config_load() {
fail 'procd lifecycle must leave UCI reads to the locked helper sync command'
}
config_foreach() {
fail 'procd lifecycle must not split helper sync by UCI section'
}
config_get_bool() {
fail 'procd lifecycle must not decide enabled state outside the helper lock'
}
"$service_entrypoint"
) || rc=$?
[ "$rc" -eq 0 ] || return "$rc"
cat "$FAKE_HELPER_LOG"
}
enabled_start_log="$(run_service start_service)"
[ "$enabled_start_log" = "sync" ] || fail "start must reconcile under one helper lock"
enabled_reload_log="$(run_service reload_service)"
[ "$enabled_reload_log" = "sync" ] || fail "reload must reconcile under one helper lock"
stop_log="$(run_service stop_service)"
[ "$stop_log" = "cleanup" ] || fail "stop must remove only owned state"
[ "$(grep -Fc '"$PROG" sync' "$INIT_SCRIPT")" -eq 1 ] || \
fail 'procd lifecycle must contain exactly one helper sync invocation'
grep -E 'config_(load|foreach|get_bool)' "$INIT_SCRIPT" >/dev/null && \
fail 'OpenClash lifecycle must not read UCI outside the helper lock'
grep -F 'procd_add_reload_trigger "tailscale_openclash"' "$INIT_SCRIPT" >/dev/null || \
fail 'OpenClash lifecycle needs its own UCI reload trigger'
grep -F 'procd_add_reload_trigger "tailscale"' "$INIT_SCRIPT" >/dev/null && \
fail 'OpenClash lifecycle must not subscribe to the core Tailscale UCI package'
grep -E '/etc/init.d/(tailscale|firewall|openclash)|fw4 (reload|restart)' "$INIT_SCRIPT" >/dev/null && \
fail 'OpenClash lifecycle must not manage Tailscale, firewall4, or OpenClash services'
echo "tailscale OpenClash lifecycle tests passed"
@@ -1,157 +0,0 @@
#!/bin/sh
set -eu
ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
HELPER="$ROOT_DIR/root/usr/sbin/tailscale_peer_probe"
TMP_DIR="${TMPDIR:-/tmp}/tailscale-peer-probe-test.$$"
cleanup() {
rm -rf "$TMP_DIR"
}
trap cleanup EXIT
fail() {
echo "FAIL: $*" >&2
exit 1
}
assert_contains() {
needle="$1"
haystack="$2"
case "$haystack" in
*"${needle}"*) ;;
*) fail "expected output to contain: $needle
actual: $haystack" ;;
esac
}
assert_not_contains() {
needle="$1"
haystack="$2"
case "$haystack" in
*"${needle}"*) fail "expected output not to contain: $needle
actual: $haystack" ;;
esac
}
assert_valid_json() {
output="$1"
if command -v node >/dev/null 2>&1; then
printf '%s' "$output" | node -e 'let s = ""; process.stdin.on("data", c => s += c); process.stdin.on("end", () => { JSON.parse(s); });' \
|| fail "expected valid JSON, actual: $output"
fi
}
mkdir -p "$TMP_DIR"
cat >"$TMP_DIR/fake-tailscale" <<'SH'
#!/bin/sh
case "$*" in
"ping --c=1 --timeout=2s direct-peer")
echo "pong from direct-peer (100.64.2.2) via 192.168.188.1:41641 in 12.4ms"
exit 0
;;
"ping --c=1 --timeout=2s control-peer")
printf 'pong from control-peer (100.64.2.3) via 192.168.188.1:41641 in 15.0ms\n"quoted" line has \\backslash\\ and tab:\t with carriage:\rreturn'
exit 0
;;
"ping --c=1 --timeout=2s derp-peer")
echo "pong from derp-peer (100.64.2.1) via DERP(test-relay) in 41.8ms"
exit 0
;;
"ping --c=1 --timeout=2s derp-no-direct-peer")
echo "pong from derp-no-direct-peer (100.64.2.6) via DERP(test-relay) in 67ms"
echo "direct connection not established"
exit 1
;;
"ping --c=1 --timeout=2s weird-peer")
echo "pong from weird-peer using new format"
exit 0
;;
"ping --c=1 --timeout=2s failed-peer")
echo 'ping "100.64.2.6" timed out' >&2
echo "no reply" >&2
exit 1
;;
*)
echo "unexpected fake tailscale args: $*" >&2
exit 99
;;
esac
SH
chmod +x "$TMP_DIR/fake-tailscale"
run_probe() {
TAILSCALE_BIN="$TMP_DIR/fake-tailscale" "$HELPER" "$@"
}
direct_output="$(run_probe direct-peer)"
assert_valid_json "$direct_output"
assert_contains '"peer":"direct-peer"' "$direct_output"
assert_contains '"ok":true' "$direct_output"
assert_contains '"path":"direct"' "$direct_output"
assert_contains '"latency_ms":12.4' "$direct_output"
assert_contains '"summary":"direct 12.4 ms"' "$direct_output"
derp_output="$(run_probe derp-peer)"
assert_valid_json "$derp_output"
assert_contains '"peer":"derp-peer"' "$derp_output"
assert_contains '"ok":true' "$derp_output"
assert_contains '"path":"derp"' "$derp_output"
assert_contains '"relay":"test-relay"' "$derp_output"
assert_contains '"latency_ms":41.8' "$derp_output"
assert_contains '"summary":"DERP test-relay 41.8 ms"' "$derp_output"
derp_no_direct_output="$(run_probe derp-no-direct-peer)"
assert_valid_json "$derp_no_direct_output"
assert_contains '"peer":"derp-no-direct-peer"' "$derp_no_direct_output"
assert_contains '"ok":true' "$derp_no_direct_output"
assert_contains '"path":"derp"' "$derp_no_direct_output"
assert_contains '"relay":"test-relay"' "$derp_no_direct_output"
assert_contains '"latency_ms":67' "$derp_no_direct_output"
assert_contains '"summary":"DERP test-relay 67 ms - direct connection not established"' "$derp_no_direct_output"
unknown_output="$(run_probe weird-peer)"
assert_valid_json "$unknown_output"
assert_contains '"peer":"weird-peer"' "$unknown_output"
assert_contains '"ok":true' "$unknown_output"
assert_contains '"path":"unknown"' "$unknown_output"
assert_contains '"summary":"unknown path"' "$unknown_output"
control_output="$(run_probe control-peer)"
assert_valid_json "$control_output"
assert_contains '"peer":"control-peer"' "$control_output"
assert_contains '"ok":true' "$control_output"
assert_contains '"path":"direct"' "$control_output"
assert_contains '"latency_ms":15.0' "$control_output"
assert_contains '"summary":"direct 15.0 ms"' "$control_output"
assert_contains '\"' "$control_output"
assert_contains '\\' "$control_output"
assert_contains '\t' "$control_output"
assert_contains '\r' "$control_output"
assert_contains '\n' "$control_output"
control_output_lines="$(printf '%s\n' "$control_output" | wc -l | tr -d ' ')"
[ "$control_output_lines" -eq 1 ] || fail "expected control peer output to be single-line JSON: $control_output"
failed_output="$(run_probe failed-peer || true)"
assert_valid_json "$failed_output"
assert_contains '"peer":"failed-peer"' "$failed_output"
assert_contains '"ok":false' "$failed_output"
assert_contains '"path":"failed"' "$failed_output"
assert_contains '"summary":"tailscale ping failed"' "$failed_output"
invalid_output="$(run_probe 'bad peer;rm -rf /' || true)"
assert_valid_json "$invalid_output"
assert_contains '"ok":false' "$invalid_output"
assert_contains '"path":"failed"' "$invalid_output"
assert_contains '"summary":"invalid peer argument"' "$invalid_output"
assert_not_contains 'rm -rf' "$invalid_output"
missing_arg_output="$("$HELPER" || true)"
assert_valid_json "$missing_arg_output"
assert_contains '"ok":false' "$missing_arg_output"
assert_contains '"summary":"usage: tailscale_peer_probe <peer>"' "$missing_arg_output"
echo "tailscale_peer_probe tests passed"
@@ -1,79 +0,0 @@
#!/bin/sh
set -eu
ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
INIT_SCRIPT="$ROOT_DIR/root/etc/init.d/tailscale-policy-routing"
HOTPLUG_SCRIPT="$ROOT_DIR/root/etc/hotplug.d/iface/98-tailscale-policy-routing"
UCI_CONFIG="$ROOT_DIR/root/etc/config/tailscale_policy_routing"
TMP_DIR="${TMPDIR:-/tmp}/tailscale-policy-routing-lifecycle-test.$$"
cleanup() { rm -rf "$TMP_DIR"; }
trap cleanup EXIT HUP INT TERM
fail() {
echo "FAIL: $*" >&2
exit 1
}
[ -x "$INIT_SCRIPT" ] || fail "missing policy-routing init script"
[ -x "$HOTPLUG_SCRIPT" ] || fail "missing policy-routing hotplug script"
[ -f "$UCI_CONFIG" ] || fail "missing policy-routing UCI config"
grep -qx "config settings 'settings'" "$UCI_CONFIG" || fail 'policy-routing UCI config must use a settings section'
mkdir -p "$TMP_DIR"
cat >"$TMP_DIR/fake-helper" <<'SH'
#!/bin/sh
case "${1:-}" in
sync|monitor) printf '%s\n' "$*" >>"${FAKE_HELPER_LOG:?}" ;;
enabled)
printf '%s\n' "$*" >>"${FAKE_HELPER_LOG:?}"
[ "${FAKE_HELPER_ENABLED:-1}" = 1 ]
;;
*) exit 2 ;;
esac
SH
chmod +x "$TMP_DIR/fake-helper"
run_service() {
entrypoint="$1"
FAKE_HELPER_LOG="$TMP_DIR/helper.log"
FAKE_PROCD_LOG="$TMP_DIR/procd.log"
: >"$FAKE_HELPER_LOG"
: >"$FAKE_PROCD_LOG"
export FAKE_HELPER_LOG FAKE_PROCD_LOG
(
procd_open_instance() { printf 'open %s\n' "${1:-default}" >>"$FAKE_PROCD_LOG"; }
procd_set_param() { printf 'set %s\n' "$*" >>"$FAKE_PROCD_LOG"; }
procd_close_instance() { printf '%s\n' 'close' >>"$FAKE_PROCD_LOG"; }
. "$INIT_SCRIPT"
PROG="$TMP_DIR/fake-helper"
"$entrypoint"
)
cat "$FAKE_HELPER_LOG" "$FAKE_PROCD_LOG"
}
expected_start="sync
enabled
open monitor
set command $TMP_DIR/fake-helper monitor
set respawn
close"
[ "$(run_service start_service)" = "$expected_start" ] || fail 'start must synchronize and monitor policy routing'
[ "$(run_service reload_service)" = "$expected_start" ] || fail 'reload must recreate the policy-routing monitor'
FAKE_HELPER_ENABLED=0
export FAKE_HELPER_ENABLED
disabled_start="$(run_service start_service)"
[ "$disabled_start" = "sync
enabled" ] || fail 'disabled policy routing must not create a monitor instance'
unset FAKE_HELPER_ENABLED
[ -z "$(run_service stop_service)" ] || fail 'ordinary stop must preserve persistent policy-routing configuration'
grep -F 'procd_add_reload_trigger "tailscale_policy_routing"' "$INIT_SCRIPT" >/dev/null || \
fail 'policy-routing service needs its own UCI reload trigger'
grep -E '/etc/init.d/(tailscale|firewall|mwan3|openclash)|fw4 (reload|restart)|mwan3 restart' "$INIT_SCRIPT" "$HOTPLUG_SCRIPT" >/dev/null && \
fail 'policy-routing lifecycle must not manage Tailscale, firewall, mwan3, or OpenClash services'
grep -F 'procd_set_param command "$PROG" monitor' "$INIT_SCRIPT" >/dev/null || \
fail 'policy-routing service must continuously reconcile Tailscale route changes'
echo 'tailscale policy-routing lifecycle tests passed'
@@ -1,279 +0,0 @@
#!/bin/sh
set -eu
ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
HELPER="$ROOT_DIR/root/usr/sbin/tailscale_policy_routing"
HOTPLUG="$ROOT_DIR/root/etc/hotplug.d/iface/98-tailscale-policy-routing"
TMP_DIR="$(mktemp -d)"
cleanup() { rm -rf "$TMP_DIR"; }
trap cleanup EXIT HUP INT TERM
fail() { echo "FAIL: $*" >&2; exit 1; }
assert_file() { [ -x "$1" ] || fail "missing executable: $1"; }
assert_contains() { grep -F -- "$1" "$2" >/dev/null || fail "$2 should contain: $1"; }
assert_not_contains() { grep -F -- "$1" "$2" >/dev/null && fail "$2 should not contain: $1" || true; }
assert_line_count() {
expected="$1"
needle="$2"
file="$3"
actual="$(grep -F -- "$needle" "$file" | wc -l | tr -d ' ')"
[ "$actual" = "$expected" ] || fail "$file expected $expected occurrences of: $needle, got $actual"
}
mkdir -p "$TMP_DIR/bin" "$TMP_DIR/uci" "$TMP_DIR/state"
cat >"$TMP_DIR/bin/uci" <<'SH'
#!/bin/sh
set -eu
state_dir="${UCI_STATE_DIR:?}"
[ "${1:-}" = '-q' ] && shift
command="${1:?}"
shift
key_file() { printf '%s/%s' "$state_dir" "$(printf '%s' "$1" | tr '/' '_')"; }
case "$command" in
get)
case "${1:?}" in
mwan3.globals) printf '%s' 'globals' ;;
mwan3.globals.mmx_mask)
file="$(key_file "$1")"
if [ -f "$file" ]; then
cat "$file"
else
printf '%s' '0x3f00'
fi
;;
*)
file="$(key_file "$1")"
[ -f "$file" ] || exit 1
cat "$file"
;;
esac
;;
set)
assignment="${1:?}"
key="${assignment%%=*}"
value="${assignment#*=}"
printf '%s' "$value" >"$(key_file "$key")"
;;
delete)
file="$(key_file "${1:?}")"
rm -f "$file" "$file".*
;;
commit)
printf '%s\n' "${1:?}" >>"${UCI_LOG:?}"
;;
*)
exit 2
;;
esac
SH
chmod +x "$TMP_DIR/bin/uci"
cat >"$TMP_DIR/bin/ip" <<'SH'
#!/bin/sh
set -eu
rules="${IP_RULES_FILE:?}"
routes="${IP_ROUTES_FILE:?}"
log="${IP_LOG:?}"
[ "${1:-}" = '-4' ] || exit 2
shift
case "$*" in
'rule show') cat "$rules" ;;
'route show table 52') cat "$routes" ;;
'rule add priority 1000 lookup 52')
grep -q '^1000:' "$rules" && exit 2
printf '%s\n' '1000: from all lookup 52' >>"$rules"
printf '%s\n' 'add priority 1000 lookup 52' >>"$log"
;;
'rule del priority 1000 lookup 52')
[ "${IP_FAIL_DELETE:-0}" != 1 ] || exit 1
tmp="$rules.tmp"
grep -v '^1000: from all lookup 52$' "$rules" >"$tmp" || true
mv "$tmp" "$rules"
printf '%s\n' 'del priority 1000 lookup 52' >>"$log"
;;
*) exit 2 ;;
esac
SH
chmod +x "$TMP_DIR/bin/ip"
cat >"$TMP_DIR/bin/logger" <<'SH'
#!/bin/sh
printf '%s\n' "$*" >>"${LOGGER_LOG:?}"
SH
chmod +x "$TMP_DIR/bin/logger"
cat >"$TMP_DIR/bin/flock" <<'SH'
#!/bin/sh
exit 0
SH
chmod +x "$TMP_DIR/bin/flock"
cat >"$TMP_DIR/bin/sleep" <<'SH'
#!/bin/sh
exit 1
SH
chmod +x "$TMP_DIR/bin/sleep"
write_uci() { printf '%s' "$2" >"$TMP_DIR/uci/$1"; }
read_uci() { cat "$TMP_DIR/uci/$1"; }
reset_state() {
rm -f "$TMP_DIR/uci"/* "$TMP_DIR/state"/*
: >"$TMP_DIR/uci.log"
: >"$TMP_DIR/ip.log"
: >"$TMP_DIR/logger.log"
printf '%s\n' '0: from all lookup local' '2001: from all fwmark 0x100/0x3f00 lookup 1' '2002: from all fwmark 0x200/0x3f00 lookup 2' '5270: from all lookup 52' '32766: from all lookup main' >"$TMP_DIR/rules"
printf '%s\n' '10.10.6.128/25 dev tailscale0' '100.64.2.0/24 dev tailscale0' >"$TMP_DIR/routes"
write_uci 'tailscale_policy_routing.settings.enabled' '1'
}
run_helper() {
UCI_BIN="$TMP_DIR/bin/uci" IP_BIN="$TMP_DIR/bin/ip" LOGGER_CMD="$TMP_DIR/bin/logger" FLOCK_BIN="$TMP_DIR/bin/flock" MWAN3_INIT="$TMP_DIR/mwan3-init" LOCK_FILE="$TMP_DIR/state/tailscale-policy-routing.lock" UCI_STATE_DIR="$TMP_DIR/uci" UCI_LOG="$TMP_DIR/uci.log" IP_RULES_FILE="$TMP_DIR/rules" IP_ROUTES_FILE="$TMP_DIR/routes" IP_LOG="$TMP_DIR/ip.log" LOGGER_LOG="$TMP_DIR/logger.log" IP_FAIL_DELETE="${IP_FAIL_DELETE:-0}" "$HELPER" "$@"
}
run_hotplug() {
ACTION="$1" POLICY_HELPER="$HELPER" UCI_BIN="$TMP_DIR/bin/uci" IP_BIN="$TMP_DIR/bin/ip" LOGGER_CMD="$TMP_DIR/bin/logger" FLOCK_BIN="$TMP_DIR/bin/flock" MWAN3_INIT="$TMP_DIR/mwan3-init" LOCK_FILE="$TMP_DIR/state/tailscale-policy-routing.lock" UCI_STATE_DIR="$TMP_DIR/uci" UCI_LOG="$TMP_DIR/uci.log" IP_RULES_FILE="$TMP_DIR/rules" IP_ROUTES_FILE="$TMP_DIR/routes" IP_LOG="$TMP_DIR/ip.log" LOGGER_LOG="$TMP_DIR/logger.log" "$HOTPLUG"
}
touch "$TMP_DIR/mwan3-init"
chmod +x "$TMP_DIR/mwan3-init"
assert_file "$HELPER"
assert_file "$HOTPLUG"
reset_state
run_helper sync
[ "$(read_uci 'network.ts_mwan3_table52')" = 'rule' ] || fail 'sync must create the owned network rule section'
[ "$(read_uci 'network.ts_mwan3_table52.family')" = 'ipv4' ] || fail 'sync must persist IPv4-only rule family'
[ "$(read_uci 'network.ts_mwan3_table52.priority')" = '1000' ] || fail 'sync must persist priority 1000'
[ "$(read_uci 'network.ts_mwan3_table52.lookup')" = '52' ] || fail 'sync must persist Tailscale table 52 lookup'
assert_contains 'network' "$TMP_DIR/uci.log"
assert_line_count 1 '1000: from all lookup 52' "$TMP_DIR/rules"
assert_line_count 1 'add priority 1000 lookup 52' "$TMP_DIR/ip.log"
run_helper sync
assert_line_count 1 '1000: from all lookup 52' "$TMP_DIR/rules"
assert_line_count 1 'add priority 1000 lookup 52' "$TMP_DIR/ip.log"
printf '%s\n' '2001: from all fwmark 0x100/0x3f00 lookup 1' '5270: from all lookup 52' >"$TMP_DIR/rules"
run_hotplug ifup
assert_line_count 1 '1000: from all lookup 52' "$TMP_DIR/rules"
assert_line_count 2 'add priority 1000 lookup 52' "$TMP_DIR/ip.log"
write_uci 'tailscale_policy_routing.settings.enabled' '0'
run_helper sync
[ ! -e "$TMP_DIR/uci/network.ts_mwan3_table52" ] || fail 'disabled sync must remove the owned UCI network rule'
assert_line_count 0 '1000: from all lookup 52' "$TMP_DIR/rules"
assert_contains 'del priority 1000 lookup 52' "$TMP_DIR/ip.log"
: >"$TMP_DIR/logger.log"
run_hotplug ifup
[ ! -s "$TMP_DIR/logger.log" ] || fail 'disabled hotplug must not report a policy-routing restore failure'
reset_state
run_helper sync
write_uci 'tailscale_policy_routing.settings.enabled' '0'
if IP_FAIL_DELETE=1 run_helper sync; then fail 'disabled sync must fail when the runtime rule cannot be deleted'; fi
[ -e "$TMP_DIR/uci/network.ts_mwan3_table52" ] || fail 'failed runtime cleanup must retain the owned UCI rule for a safe retry'
assert_line_count 1 '1000: from all lookup 52' "$TMP_DIR/rules"
IP_FAIL_DELETE=0
run_helper sync
[ ! -e "$TMP_DIR/uci/network.ts_mwan3_table52" ] || fail 'a later successful cleanup must remove the owned UCI rule'
assert_line_count 0 '1000: from all lookup 52' "$TMP_DIR/rules"
reset_state
printf '%s\n' '1000: from all lookup 52' '2001: from all fwmark 0x100/0x3f00 lookup 1' '5270: from all lookup 52' >"$TMP_DIR/rules"
write_uci 'tailscale_policy_routing.settings.enabled' '0'
run_helper sync
assert_line_count 1 '1000: from all lookup 52' "$TMP_DIR/rules"
[ ! -e "$TMP_DIR/uci/network.ts_mwan3_table52" ] || fail 'disabled sync must not adopt a manually-created exact rule'
reset_state
run_helper sync
printf '%s\n' '1000: from 10.10.10.0/24 lookup 52' '2001: from all fwmark 0x100/0x3f00 lookup 1' '5270: from all lookup 52' >"$TMP_DIR/rules"
write_uci 'tailscale_policy_routing.settings.enabled' '0'
if run_helper sync; then fail 'disabled sync must refuse to delete a constrained table 52 rule'; fi
[ -e "$TMP_DIR/uci/network.ts_mwan3_table52" ] || fail 'constrained-rule cleanup failure must retain UCI ownership for retry'
assert_line_count 1 '1000: from 10.10.10.0/24 lookup 52' "$TMP_DIR/rules"
reset_state
printf '%s\n' '1000: from all lookup 1' '2001: from all fwmark 0x100/0x3f00 lookup 1' '5270: from all lookup 52' >"$TMP_DIR/rules"
if run_helper sync; then fail 'sync must reject an occupied priority 1000'; fi
[ ! -e "$TMP_DIR/uci/network.ts_mwan3_table52" ] || fail 'priority conflict must not create an owned UCI rule'
reset_state
printf '%s\n' '1000: from all lookup 52' '2001: from all fwmark 0x100/0x3f00 lookup 1' '5270: from all lookup 52' >"$TMP_DIR/rules"
if run_helper sync; then fail 'sync must reject an unowned pre-existing table 52 priority rule'; fi
[ ! -e "$TMP_DIR/uci/network.ts_mwan3_table52" ] || fail 'unowned runtime rule must not be adopted'
reset_state
run_helper sync
printf '%s\n' '1000: from all lookup 1' '2001: from all fwmark 0x100/0x3f00 lookup 1' '5270: from all lookup 52' >"$TMP_DIR/rules"
if run_helper sync; then fail 'sync must reject a priority conflict introduced after enablement'; fi
[ -e "$TMP_DIR/uci/network.ts_mwan3_table52" ] || fail 'priority conflict must retain the app-owned UCI rule for safe recovery'
assert_line_count 1 '1000: from all lookup 1' "$TMP_DIR/rules"
printf '%s\n' '2001: from all fwmark 0x100/0x3f00 lookup 1' '5270: from all lookup 52' >"$TMP_DIR/rules"
run_helper sync
assert_line_count 1 '1000: from all lookup 52' "$TMP_DIR/rules"
reset_state
printf '%s\n' 'default dev tailscale0' >"$TMP_DIR/routes"
run_helper sync
[ ! -e "$TMP_DIR/uci/network.ts_mwan3_table52" ] || fail 'default route in table 52 must prevent persistent precedence rule creation'
assert_line_count 0 '1000: from all lookup 52' "$TMP_DIR/rules"
status="$(run_helper status)"
printf '%s' "$status" | jq -e '.state == "blocked_default_route" and .enabled == true and .mwan3_present == true and .mwan3_earlier_mark_rule == false' >/dev/null || fail 'status must identify the exit-node default route block without treating later mwan3 rules as earlier than priority 1000'
reset_state
run_helper sync
printf '%s\n' 'default dev tailscale0' >"$TMP_DIR/routes"
run_hotplug ifupdate
[ -e "$TMP_DIR/uci/network.ts_mwan3_table52" ] || fail 'hotplug default-route protection must retain the owned UCI declaration for recovery'
assert_line_count 0 '1000: from all lookup 52' "$TMP_DIR/rules" # route-only ifupdate must remove the runtime rule
printf '%s\n' '10.10.6.128/25 dev tailscale0' '100.64.2.0/24 dev tailscale0' >"$TMP_DIR/routes"
run_hotplug ifup
assert_line_count 1 '1000: from all lookup 52' "$TMP_DIR/rules"
printf '%s\n' 'default dev tailscale0' >"$TMP_DIR/routes"
run_helper sync
[ ! -e "$TMP_DIR/uci/network.ts_mwan3_table52" ] || fail 'main sync must remove the persistent rule after an exit-node default route appears'
assert_line_count 0 '1000: from all lookup 52' "$TMP_DIR/rules"
reset_state
run_helper sync
printf '%s\n' '500: from all fwmark 0x100/0x3f00 lookup 1' '1000: from all lookup 52' '2001: from all fwmark 0x200/0x3f00 lookup 2' '5270: from all lookup 52' >"$TMP_DIR/rules"
run_helper ensure
[ -e "$TMP_DIR/uci/network.ts_mwan3_table52" ] || fail 'earlier mwan3 priority must retain UCI ownership for recovery'
assert_line_count 0 '1000: from all lookup 52' "$TMP_DIR/rules"
status="$(run_helper status)"
printf '%s' "$status" | jq -e '.state == "blocked_mwan3_priority" and .mwan3_earlier_mark_rule == true' >/dev/null || fail 'status must block when mwan3 precedes priority 1000'
reset_state
write_uci 'mwan3.globals.mmx_mask' '0x3F00'
printf '%s\n' '500: from all fwmark 0x100/0x3f00 lookup 1' '5270: from all lookup 52' >"$TMP_DIR/rules"
run_helper sync
[ ! -e "$TMP_DIR/uci/network.ts_mwan3_table52" ] || fail 'uppercase mwan3 mask must still block priority 1000 creation'
assert_line_count 0 '1000: from all lookup 52' "$TMP_DIR/rules"
reset_state
printf '%s\n' '1000: from all lookup 52' '2001: from all fwmark 0x100/0x3f00 lookup 1' '5270: from all lookup 52' >"$TMP_DIR/rules"
rm -f "$TMP_DIR/uci/network.ts_mwan3_table52" "$TMP_DIR/uci/network.ts_mwan3_table52".*
if run_helper cleanup; then fail 'package cleanup must refuse to leave an unowned exact runtime rule'; fi
assert_line_count 1 '1000: from all lookup 52' "$TMP_DIR/rules"
reset_state
run_helper sync
printf '%s\n' '2001: from all fwmark 0x100/0x3f00 lookup 1' '2002: from all fwmark 0x200/0x3f00 lookup 2' '5270: from all lookup 52' >"$TMP_DIR/rules"
SLEEP_BIN="$TMP_DIR/bin/sleep"
export SLEEP_BIN
run_helper monitor
unset SLEEP_BIN
assert_line_count 1 '1000: from all lookup 52' "$TMP_DIR/rules"
assert_not_contains '/etc/init.d/network' "$HELPER"
assert_not_contains 'firewall' "$HELPER"
assert_not_contains 'openclash' "$HELPER"
assert_not_contains 'mwan3 restart' "$HELPER"
echo 'tailscale policy routing tests passed'
@@ -1,254 +0,0 @@
#!/bin/sh
set -eu
ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
RPCD_SCRIPT="$ROOT_DIR/root/usr/libexec/rpcd/luci.tailscale"
TMP_DIR="${TMPDIR:-/tmp}/tailscale-rpcd-test.$$"
cleanup() {
rm -rf "$TMP_DIR"
}
trap cleanup EXIT
fail() {
echo "FAIL: $*" >&2
exit 1
}
mkdir -p "$TMP_DIR"
REAL_JQ="$(command -v jq)"
export REAL_JQ
cat >"$TMP_DIR/jq-no-regex" <<'SH'
#!/bin/sh
filter=
skip=0
for arg do
if [ "$skip" -gt 0 ]; then
skip=$((skip - 1))
continue
fi
[ -z "$filter" ] || continue
case "$arg" in
--arg|--argjson|--argfile|--slurpfile|--rawfile) skip=2 ;;
-*) ;;
*) filter="$arg" ;;
esac
done
normalized_filter="$(printf '%s' "$filter" | tr '\n\r\t' ' ')"
if printf '%s\n' "$normalized_filter" |
grep -Eq '(^|[^[:alnum:]_])(test|match|sub|gsub)[[:space:]]*\('; then
printf '%s\n' 'jq regex functions are unavailable on the target OpenWrt build' >&2
exit 3
fi
exec "${REAL_JQ:?}" "$@"
SH
chmod +x "$TMP_DIR/jq-no-regex"
export JQ_BIN="$TMP_DIR/jq-no-regex"
if "$TMP_DIR/jq-no-regex" -n '"x" | test
("x")' >/dev/null 2>&1; then
fail "target jq shim must reject regex functions split across whitespace"
fi
cat >"$TMP_DIR/secrets" <<'SH'
#!/bin/sh
printf '%s\n' "$*" >>"${SECRETS_ARGV_LOG:?}"
case "${1:-}" in
has)
case "${2:-}" in
authkey|adguard_password) exit 0 ;;
esac
;;
matches-adguard)
[ "${2:-}" = 'http://saved.example:3000' ] && [ "${3:-}" = 'saved-user' ]
;;
stage-batch)
cat >"${SECRETS_STDIN_LOG:?}"
printf '%s\n' 'staged-ref'
;;
migrate) ;;
*) exit 1 ;;
esac
SH
chmod +x "$TMP_DIR/secrets"
cat >"$TMP_DIR/preflight" <<'SH'
#!/bin/sh
printf '%s\n' "$*" >"${PREFLIGHT_ARGV_LOG:?}"
{
printf 'candidate=%s\n' "${TAILSCALE_ADGUARD_PREFLIGHT_CANDIDATE:-}"
printf 'api_url=%s\n' "${TAILSCALE_ADGUARD_PREFLIGHT_API_URL:-}"
printf 'username=%s\n' "${TAILSCALE_ADGUARD_PREFLIGHT_USERNAME:-}"
printf 'password_set=%s\n' "${TAILSCALE_ADGUARD_PREFLIGHT_PASSWORD_SET:-}"
printf 'password=%s\n' "${TAILSCALE_ADGUARD_PREFLIGHT_PASSWORD:-}"
printf 'default_upstreams=%s\n' "${TAILSCALE_ADGUARD_PREFLIGHT_DEFAULT_UPSTREAMS:-}"
printf 'tailnet_upstreams=%s\n' "${TAILSCALE_ADGUARD_PREFLIGHT_TAILNET_UPSTREAMS:-}"
printf 'health_domain=%s\n' "${TAILSCALE_ADGUARD_PREFLIGHT_HEALTH_DOMAIN:-}"
printf 'expected_ips=%s\n' "${TAILSCALE_ADGUARD_PREFLIGHT_EXPECTED_IPS:-}"
} >"${PREFLIGHT_ENV_LOG:?}"
printf 'adguard_process=pass\nhealth_check=pass\nready=pass\n'
SH
chmod +x "$TMP_DIR/preflight"
cat >"$TMP_DIR/openclash-helper" <<'SH'
#!/bin/sh
printf '%s\n' "$*" >"${OPENCLASH_HELPER_ARGV_LOG:?}"
case "${OPENCLASH_HELPER_MODE:-success}" in
success)
printf '%s\n' '{"state":"active","rules_present":4,"detail":"helper status"}'
;;
fail)
printf '%s\n' 'partial helper output'
printf '%s\n' 'helper failed' >&2
exit 7
;;
invalid)
printf '%s\n' 'not JSON'
;;
esac
SH
chmod +x "$TMP_DIR/openclash-helper"
cat >"$TMP_DIR/policy-routing-helper" <<'SH'
#!/bin/sh
printf '%s\n' "$*" >"${POLICY_ROUTING_HELPER_ARGV_LOG:?}"
case "${POLICY_ROUTING_HELPER_MODE:-success}" in
success)
printf '%s\n' '{"state":"active","enabled":true,"mwan3_present":true}'
;;
fail)
printf '%s\n' 'partial helper output'
printf '%s\n' 'helper failed' >&2
exit 7
;;
invalid)
printf '%s\n' 'not JSON'
;;
esac
SH
chmod +x "$TMP_DIR/policy-routing-helper"
PREFLIGHT_ARGV_LOG="$TMP_DIR/argv.log"
PREFLIGHT_ENV_LOG="$TMP_DIR/env.log"
SECRETS_ARGV_LOG="$TMP_DIR/secrets-argv.log"
SECRETS_STDIN_LOG="$TMP_DIR/secrets-stdin.log"
OPENCLASH_HELPER_ARGV_LOG="$TMP_DIR/openclash-helper-argv.log"
POLICY_ROUTING_HELPER_ARGV_LOG="$TMP_DIR/policy-routing-helper-argv.log"
export PREFLIGHT_ARGV_LOG PREFLIGHT_ENV_LOG SECRETS_ARGV_LOG SECRETS_STDIN_LOG OPENCLASH_HELPER_ARGV_LOG POLICY_ROUTING_HELPER_ARGV_LOG
: >"$SECRETS_ARGV_LOG"
: >"$SECRETS_STDIN_LOG"
list_json="$(PRECHECK_BIN="$TMP_DIR/preflight" SECRETS_BIN="$TMP_DIR/secrets" "$RPCD_SCRIPT" list)"
printf '%s' "$list_json" | jq -e '.adguard_preflight.candidate == "" and .adguard_preflight.password == ""' >/dev/null || \
fail "rpcd list output must declare the AdGuard preflight string arguments"
printf '%s' "$list_json" | jq -e '.secret_status == {} and (has("set_secret") | not) and .set_secrets.authkey_set == "" and .set_secrets.adguard_password_set == "" and .set_secrets.base_ref == ""' >/dev/null || \
fail "rpcd list output must declare secret status and write methods"
printf '%s' "$list_json" | jq -e '.openclash_bypass_status == {}' >/dev/null || \
fail "rpcd list output must declare the read-only OpenClash bypass status method"
printf '%s' "$list_json" | jq -e '.policy_routing_status == {}' >/dev/null || \
fail "rpcd list output must declare the read-only policy-routing status method"
openclash_status_response="$(printf '%s\n' '{"ignored":"caller-controlled input"}' | OPENCLASH_BYPASS_BIN="$TMP_DIR/openclash-helper" \
"$RPCD_SCRIPT" call openclash_bypass_status)"
printf '%s' "$openclash_status_response" | jq -e '.state == "active" and .rules_present == 4 and .detail == "helper status"' >/dev/null || \
fail "rpcd must return the helper status object unchanged"
[ "$(cat "$OPENCLASH_HELPER_ARGV_LOG")" = "status" ] || \
fail "rpcd must invoke the OpenClash helper with the fixed status argument only"
if openclash_failure_response="$(printf '%s\n' '{}' | OPENCLASH_HELPER_MODE=fail OPENCLASH_BYPASS_BIN="$TMP_DIR/openclash-helper" \
"$RPCD_SCRIPT" call openclash_bypass_status 2>&1)"; then
fail "rpcd must fail when the OpenClash helper exits nonzero"
fi
printf '%s' "$openclash_failure_response" | jq -e '.code == 2 and .ready == "fail" and (.error | type == "string")' >/dev/null || \
fail "nonzero OpenClash helper exits must return controlled JSON errors"
if openclash_invalid_response="$(printf '%s\n' '{}' | OPENCLASH_HELPER_MODE=invalid OPENCLASH_BYPASS_BIN="$TMP_DIR/openclash-helper" \
"$RPCD_SCRIPT" call openclash_bypass_status 2>&1)"; then
fail "rpcd must fail when the OpenClash helper returns invalid JSON"
fi
printf '%s' "$openclash_invalid_response" | jq -e '.code == 2 and .ready == "fail" and (.error | type == "string")' >/dev/null || \
fail "invalid OpenClash helper JSON must return controlled JSON errors"
policy_status_response="$(printf '%s\n' '{"ignored":"caller-controlled input"}' | POLICY_ROUTING_BIN="$TMP_DIR/policy-routing-helper" \
"$RPCD_SCRIPT" call policy_routing_status)"
printf '%s' "$policy_status_response" | jq -e '.state == "active" and .enabled == true and .mwan3_present == true' >/dev/null || \
fail "rpcd must return the policy-routing helper status object unchanged"
[ "$(cat "$POLICY_ROUTING_HELPER_ARGV_LOG")" = "status" ] || \
fail "rpcd must invoke the policy-routing helper with the fixed status argument only"
if policy_failure_response="$(printf '%s\n' '{}' | POLICY_ROUTING_HELPER_MODE=fail POLICY_ROUTING_BIN="$TMP_DIR/policy-routing-helper" \
"$RPCD_SCRIPT" call policy_routing_status 2>&1)"; then
fail "rpcd must fail when the policy-routing helper exits nonzero"
fi
printf '%s' "$policy_failure_response" | jq -e '.code == 2 and .ready == "fail" and (.error | type == "string")' >/dev/null || \
fail "nonzero policy-routing helper exits must return controlled JSON errors"
if policy_invalid_response="$(printf '%s\n' '{}' | POLICY_ROUTING_HELPER_MODE=invalid POLICY_ROUTING_BIN="$TMP_DIR/policy-routing-helper" \
"$RPCD_SCRIPT" call policy_routing_status 2>&1)"; then
fail "rpcd must fail when the policy-routing helper returns invalid JSON"
fi
printf '%s' "$policy_invalid_response" | jq -e '.code == 2 and .ready == "fail" and (.error | type == "string")' >/dev/null || \
fail "invalid policy-routing helper JSON must return controlled JSON errors"
status_response="$(printf '%s\n' '{}' | SECRETS_BIN="$TMP_DIR/secrets" "$RPCD_SCRIPT" call secret_status)"
printf '%s' "$status_response" | jq -e '.authkey_set == "1" and .adguard_password_set == "1"' >/dev/null || \
fail "secret status must expose booleans without returning secret values"
grep -Fx 'migrate' "$SECRETS_ARGV_LOG" >/dev/null || fail "secret status must migrate readable legacy credentials before reporting status"
request='{"candidate":"1","api_url":"http://candidate.example:3000","username":"candidate-user","password_set":"1","password":"candidate-secret","default_upstreams":"1.1.1.1\\n8.8.8.8","tailnet_upstreams":"[/candidate.example/]100.100.100.100","health_domain":"candidate-health.example","expected_ips":"10.23.0.15\\n10.23.0.16"}'
response="$(printf '%s\n' "$request" | PRECHECK_BIN="$TMP_DIR/preflight" SECRETS_BIN="$TMP_DIR/secrets" "$RPCD_SCRIPT" call adguard_preflight)"
printf '%s' "$response" | jq -e '.ready == "pass" and .health_check == "pass" and .code == 0' >/dev/null || \
fail "rpcd preflight must return the structured checker result"
[ "$(cat "$PREFLIGHT_ARGV_LOG")" = "--preflight" ] || fail "candidate values and credentials must not enter the checker argv"
grep -F 'candidate=1' "$PREFLIGHT_ENV_LOG" >/dev/null || fail "rpcd preflight must enable candidate mode"
grep -F 'api_url=http://candidate.example:3000' "$PREFLIGHT_ENV_LOG" >/dev/null || fail "rpcd preflight must pass the candidate API URL"
grep -F 'password=candidate-secret' "$PREFLIGHT_ENV_LOG" >/dev/null || fail "rpcd preflight must pass the candidate password through the child environment"
grep -F 'default_upstreams=1.1.1.1' "$PREFLIGHT_ENV_LOG" >/dev/null || fail "rpcd preflight must preserve candidate upstreams"
: >"$PREFLIGHT_ARGV_LOG"
reuse_request='{"candidate":"1","api_url":"http://attacker.example:3000","username":"saved-user","password_set":"0","password":"","default_upstreams":"1.1.1.1","tailnet_upstreams":"[/candidate.example/]100.100.100.100","health_domain":"candidate-health.example","expected_ips":"10.23.0.15"}'
if printf '%s\n' "$reuse_request" | PRECHECK_BIN="$TMP_DIR/preflight" SECRETS_BIN="$TMP_DIR/secrets" "$RPCD_SCRIPT" call adguard_preflight >/dev/null 2>&1; then
fail "preflight must reject saved-password reuse for a different AdGuard endpoint"
fi
[ ! -s "$PREFLIGHT_ARGV_LOG" ] || fail "rejected credential reuse must not invoke the preflight checker"
: >"$PREFLIGHT_ARGV_LOG"
unsafe_request='{"candidate":"1","api_url":"file:///etc/shadow","username":"candidate-user","password_set":"1","password":"candidate-secret","default_upstreams":"1.1.1.1","tailnet_upstreams":"[/candidate.example/]100.100.100.100","health_domain":"candidate-health.example","expected_ips":"10.23.0.15"}'
if printf '%s\n' "$unsafe_request" | PRECHECK_BIN="$TMP_DIR/preflight" SECRETS_BIN="$TMP_DIR/secrets" "$RPCD_SCRIPT" call adguard_preflight >/dev/null 2>&1; then
fail "preflight must reject non-HTTP AdGuard API URLs"
fi
[ ! -s "$PREFLIGHT_ARGV_LOG" ] || fail "rejected API schemes must not invoke the preflight checker"
: >"$SECRETS_ARGV_LOG"
: >"$SECRETS_STDIN_LOG"
batch_request='{"authkey_set":"1","authkey":"new-auth-key","adguard_password_set":"1","adguard_password":"new-batch-secret","api_url":"https://batch.example:3000","username":"batch-user","base_ref":"active-ref"}'
batch_response="$(printf '%s\n' "$batch_request" | SECRETS_BIN="$TMP_DIR/secrets" "$RPCD_SCRIPT" call set_secrets)"
[ "$(cat "$SECRETS_ARGV_LOG")" = "stage-batch active-ref" ] || fail "batch credential writes must stage one atomic secret version based on the current UCI reference"
printf '%s' "$(cat "$SECRETS_STDIN_LOG")" | jq -e '.authkey == "new-auth-key" and .adguard.password == "new-batch-secret" and .adguard.api_url == "https://batch.example:3000" and .adguard.username == "batch-user"' >/dev/null || \
fail "batch credential writes must preserve both credentials and the AdGuard endpoint binding"
printf '%s' "$batch_response" | jq -e '.code == 0 and .ref == "staged-ref"' >/dev/null || \
fail "batch credential staging must return the UCI version reference without activating it"
for invalid_ref_request in \
'{"authkey_set":"1","authkey":"new-auth-key","adguard_password_set":"0","adguard_password":"","api_url":"","username":"","base_ref":"active-ref\n\n"}' \
'{"authkey_set":"1","authkey":"new-auth-key","adguard_password_set":"0","adguard_password":"","api_url":"","username":"","base_ref":"active-ref\u0000"}'
do
: >"$SECRETS_ARGV_LOG"
if printf '%s\n' "$invalid_ref_request" | SECRETS_BIN="$TMP_DIR/secrets" "$RPCD_SCRIPT" call set_secrets >/dev/null 2>&1; then
fail "credential batch RPC must reject control characters in base_ref"
fi
[ ! -s "$SECRETS_ARGV_LOG" ] || fail "invalid base_ref must not reach the protected credential helper"
done
legacy_set_request='{"name":"adguard_password","value":"new-secret","api_url":"http://new.example:3000","username":"new-user"}'
if printf '%s\n' "$legacy_set_request" | SECRETS_BIN="$TMP_DIR/secrets" "$RPCD_SCRIPT" call set_secret >/dev/null 2>&1; then
fail "the non-transactional legacy secret write RPC must fail closed"
fi
printf '%s\n' '{}' | PRECHECK_BIN="$TMP_DIR/preflight" SECRETS_BIN="$TMP_DIR/secrets" "$RPCD_SCRIPT" call unknown >/dev/null 2>&1 && \
fail "unknown rpcd methods must fail closed"
echo "tailscale rpcd tests passed"
@@ -1,220 +0,0 @@
#!/bin/sh
set -eu
ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
SCRIPT="$ROOT_DIR/root/usr/sbin/tailscale_secrets"
TMP_DIR="${TMPDIR:-/tmp}/tailscale-secrets-test.$$"
SECRET_FILE="$TMP_DIR/luci-secrets.json"
UCI_LOG="$TMP_DIR/uci.log"
UCI_REF_FILE="$TMP_DIR/uci-secrets-ref"
cleanup() {
rm -rf "$TMP_DIR"
}
trap cleanup EXIT
fail() {
printf 'FAIL: %s\n' "$*" >&2
exit 1
}
assert_eq() {
expected="$1"
actual="$2"
message="$3"
[ "$expected" = "$actual" ] || fail "$message
expected: $expected
actual: $actual"
}
file_mode() {
stat -c '%a' "$1" 2>/dev/null || stat -f '%Lp' "$1"
}
mkdir -p "$TMP_DIR"
: >"$UCI_LOG"
export UCI_LOG UCI_REF_FILE
REAL_JQ="$(command -v jq)"
export REAL_JQ
cat >"$TMP_DIR/jq-no-regex" <<'SH'
#!/bin/sh
filter=
skip=0
for arg do
if [ "$skip" -gt 0 ]; then
skip=$((skip - 1))
continue
fi
[ -z "$filter" ] || continue
case "$arg" in
--arg|--argjson|--argfile|--slurpfile|--rawfile) skip=2 ;;
-*) ;;
*) filter="$arg" ;;
esac
done
normalized_filter="$(printf '%s' "$filter" | tr '\n\r\t' ' ')"
if printf '%s\n' "$normalized_filter" |
grep -Eq '(^|[^[:alnum:]_])(test|match|sub|gsub)[[:space:]]*\('; then
printf '%s\n' 'jq regex functions are unavailable on the target OpenWrt build' >&2
exit 3
fi
exec "${REAL_JQ:?}" "$@"
SH
chmod +x "$TMP_DIR/jq-no-regex"
if "$TMP_DIR/jq-no-regex" -n '"x" | test ("x")' >/dev/null 2>&1; then
fail "target jq shim must reject regex functions separated from their opening parenthesis by whitespace"
fi
cat >"$TMP_DIR/uci" <<'SH'
#!/bin/sh
printf '%s\n' "$*" >>"${UCI_LOG:?}"
case "$*" in
"-q get tailscale.settings.authkey") printf '%s\n' "${LEGACY_AUTHKEY:-}" ;;
"-q get tailscale.settings.adguard_password") printf '%s\n' "${LEGACY_ADGUARD_PASSWORD:-}" ;;
"-q get tailscale.settings.adguard_api_url") printf '%s\n' "${LEGACY_ADGUARD_API_URL:-http://127.0.0.1:3000}" ;;
"-q get tailscale.settings.adguard_username") printf '%s\n' "${LEGACY_ADGUARD_USERNAME:-}" ;;
"-q get tailscale.settings.secrets_ref") [ -f "$UCI_REF_FILE" ] && cat "$UCI_REF_FILE" ;;
"-q set tailscale.settings.secrets_ref="*) printf '%s\n' "${3#*=}" >"$UCI_REF_FILE" ;;
"-q delete tailscale.settings.authkey"|"-q delete tailscale.settings.adguard_password"|"commit tailscale") ;;
*) exit 1 ;;
esac
SH
chmod +x "$TMP_DIR/uci"
if command -v flock >/dev/null 2>&1; then
FLOCK_CMD="$(command -v flock)"
else
cat >"$TMP_DIR/flock" <<'SH'
#!/bin/sh
exit 0
SH
chmod +x "$TMP_DIR/flock"
FLOCK_CMD="$TMP_DIR/flock"
fi
run_secrets() {
TAILSCALE_SECRETS_FILE="$SECRET_FILE" \
UCI_CMD="$TMP_DIR/uci" \
JQ_CMD="$TMP_DIR/jq-no-regex" \
FLOCK_BIN="$FLOCK_CMD" \
"$SCRIPT" "$@"
}
for encoded_ref in '"base\n\n"' '"base\u0000"'; do
jq -nc --argjson ref "$encoded_ref" '{schema:2,active_ref:$ref,versions:{($ref):{}}}' >"$SECRET_FILE"
if run_secrets active-ref >/dev/null 2>&1; then
fail "protected credential store must reject control characters in active_ref"
fi
rm -f "$SECRET_FILE"
done
authkey='tskey-auth-special-$HOME-$(not-expanded)-"quoted"-\backslash'
printf '%s' "$authkey" | run_secrets set-authkey
assert_eq "$authkey" "$(run_secrets get authkey)" "auth key must round-trip without shell expansion"
run_secrets authkey-pending || fail "newly saved auth keys must be marked for one-time enrollment"
jq -e '.versions[.active_ref].authkey_pending == true' "$SECRET_FILE" >/dev/null || \
fail "active auth key version must record pending enrollment intent"
assert_eq 600 "$(file_mode "$SECRET_FILE")" "secret file must be root-only"
if find "$TMP_DIR" -maxdepth 1 -type f -name '.luci-secrets-*' | grep -q .; then
fail "secret updates must not leave temporary credential files behind"
fi
adguard_password='adguard-password-$HOME-$(not-expanded)-"quoted"-\backslash'
jq -nc \
--arg password "$adguard_password" \
--arg api_url 'http://127.0.0.1:3000/' \
--arg username 'root' \
'{password:$password,api_url:$api_url,username:$username}' | run_secrets set-adguard
run_secrets matches-adguard 'http://127.0.0.1:3000' root || fail "saved AdGuard binding must match its normalized endpoint"
assert_eq "$adguard_password" "$(run_secrets adguard-password-for 'http://127.0.0.1:3000/' root)" "matching AdGuard endpoint must receive the saved password"
if find "$TMP_DIR" -maxdepth 1 -type f -name '.luci-secrets-*' | grep -q .; then
fail "AdGuard secret updates must clean temporary credential files"
fi
if run_secrets matches-adguard 'http://attacker.example:3000' root; then
fail "changed AdGuard endpoint must not match the saved credential binding"
fi
if run_secrets adguard-password-for 'http://attacker.example:3000' root >"$TMP_DIR/mismatch.out"; then
fail "changed AdGuard endpoint must not receive the saved password"
fi
[ ! -s "$TMP_DIR/mismatch.out" ] || fail "mismatched AdGuard endpoint leaked the saved password"
if jq -nc '{password:"secret",api_url:"file:///etc/shadow",username:"root"}' | run_secrets set-adguard >/dev/null 2>&1; then
fail "AdGuard credentials must only bind to HTTP or HTTPS API URLs"
fi
batch_authkey='batch-auth-key'
batch_password='batch-adguard-password'
jq -nc \
--arg authkey "$batch_authkey" \
--arg password "$batch_password" \
'{authkey:$authkey,adguard:{password:$password,api_url:"https://adguard.example:3000/",username:"batch-user"}}' |
run_secrets set-batch
assert_eq "$batch_authkey" "$(run_secrets get authkey)" "batch updates must atomically preserve the auth key"
run_secrets authkey-pending || fail "batch auth key updates must be marked for one-time enrollment"
assert_eq "$batch_password" "$(run_secrets adguard-password-for 'https://adguard.example:3000' batch-user)" "batch updates must atomically preserve the bound AdGuard password"
[ -f "$TMP_DIR/.luci-secrets.lock" ] || fail "secret access must use a persistent lock file"
assert_eq 600 "$(file_mode "$TMP_DIR/.luci-secrets.lock")" "secret lock file must be root-only"
jq -e '.schema == 2 and (.active_ref | type == "string") and (.versions[.active_ref] | type == "object")' "$SECRET_FILE" >/dev/null || \
fail "secret storage must use a versioned schema with an explicit active reference"
active_ref="$(run_secrets active-ref)"
staged_auth_ref="$(printf '%s' 'staged-auth-key' | run_secrets stage-authkey "$active_ref")"
assert_eq "$batch_authkey" "$(run_secrets get authkey)" "staging a credential version must not change the active auth key"
jq -e --arg ref "$staged_auth_ref" '.versions[$ref].authkey_pending == true' "$SECRET_FILE" >/dev/null || \
fail "staged auth key version must record pending enrollment intent"
staged_combined_ref="$(
jq -nc '{authkey:null,adguard:{password:"staged-adguard-password",api_url:"https://staged.example:3000",username:"staged-user"}}' |
run_secrets stage-batch "$staged_auth_ref"
)"
run_secrets activate "$staged_combined_ref"
assert_eq 'staged-auth-key' "$(run_secrets get authkey)" "a second staged update must inherit the first staged version"
assert_eq 'staged-adguard-password' "$(run_secrets adguard-password-for 'https://staged.example:3000' staged-user)" "activating a staged version must expose its bound AdGuard password"
run_secrets activate "$active_ref"
assert_eq "$batch_authkey" "$(run_secrets get authkey)" "reactivating the previous UCI reference must roll credentials back"
run_secrets clear-authkey
if run_secrets has authkey; then
fail "successful enrollment cleanup must remove the active auth key"
fi
if run_secrets authkey-pending; then
fail "successful enrollment cleanup must remove pending enrollment intent"
fi
jq -e '[.versions[] | has("authkey") or has("authkey_pending")] | any | not' "$SECRET_FILE" >/dev/null || \
fail "auth key cleanup must purge every credential version"
assert_eq "$batch_password" "$(run_secrets adguard-password-for 'https://adguard.example:3000' batch-user)" \
"auth key cleanup must preserve unrelated AdGuard credentials"
printf '%s\n' '{"authkey":"flat-file-auth"}' >"$SECRET_FILE"
rm -f "$UCI_REF_FILE"
unset LEGACY_AUTHKEY LEGACY_ADGUARD_PASSWORD LEGACY_ADGUARD_API_URL LEGACY_ADGUARD_USERNAME || true
run_secrets migrate
assert_eq 'flat-file-auth' "$(run_secrets get authkey)" "migration must preserve the pre-versioned secret file"
if run_secrets authkey-pending; then
fail "migrated legacy auth keys must not trigger re-enrollment on an existing node"
fi
jq -e '.schema == 2 and (.versions[.active_ref].authkey == "flat-file-auth")' "$SECRET_FILE" >/dev/null || \
fail "migration must wrap the old flat secret object in a version"
rm -f "$SECRET_FILE"
rm -f "$UCI_REF_FILE"
export LEGACY_AUTHKEY='legacy-auth-key'
export LEGACY_ADGUARD_PASSWORD='legacy-adguard-password'
export LEGACY_ADGUARD_API_URL='http://legacy.example:3000/'
export LEGACY_ADGUARD_USERNAME='legacy-user'
run_secrets migrate
assert_eq 'legacy-auth-key' "$(run_secrets get authkey)" "migration must preserve the legacy auth key"
if run_secrets authkey-pending; then
fail "readable legacy auth keys must not be treated as explicit new enrollment requests"
fi
assert_eq 'legacy-adguard-password' "$(run_secrets adguard-password-for 'http://legacy.example:3000' legacy-user)" "migration must bind and preserve the legacy AdGuard password"
grep -F -- '-q delete tailscale.settings.authkey' "$UCI_LOG" >/dev/null || fail "migration must delete the readable legacy auth key"
grep -F -- '-q delete tailscale.settings.adguard_password' "$UCI_LOG" >/dev/null || fail "migration must delete the readable legacy AdGuard password"
grep -F -- '-q set tailscale.settings.secrets_ref=' "$UCI_LOG" >/dev/null || fail "migration must persist the active secret version reference"
grep -F -- 'commit tailscale' "$UCI_LOG" >/dev/null || fail "migration must commit removal of legacy credentials"
printf '%s\n' 'tailscale secret storage tests passed'