From a96317f317a483bad37598890a0cd9f426ffa388 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 29 Sep 2026 08:28:44 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=A4=20Sync=202026-09-29=2008:28:44?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- luci-app-substore/CHANGELOG.md | 30 + luci-app-substore/Makefile | 2 +- luci-app-substore/README.en.md | 6 +- luci-app-substore/README.md | 6 +- luci-app-substore/docs/ARCHITECTURE.md | 2 +- luci-app-substore/docs/INSTALL.md | 6 +- luci-app-substore/docs/TESTING.md | 6 +- .../root/usr/share/substore/node.lua | 35 + .../usr/share/substore/output_singbox.lua | 3 +- .../root/usr/share/substore/output_uri.lua | 7 +- .../root/usr/share/substore/output_v2ray.lua | 3 +- .../root/usr/share/substore/parser.lua | 13 +- .../usr/share/substore/parser_json_config.lua | 17 +- luci-app-substore/tests/vmess_cipher_test.lua | 188 +++ luci-app-tailscale-ng/.deploy/README.md | 77 ++ luci-app-tailscale-ng/.deploy/deploy.sh | 374 +++++ luci-app-tailscale-ng/.deploy/mapping.tsv | 10 + .../.deploy/target.env.example | 5 + luci-app-tailscale-ng/.gitattributes | 19 + luci-app-tailscale-ng/.gitignore | 3 + luci-app-tailscale-ng/LICENSE | 674 +++++++++ luci-app-tailscale-ng/Makefile | 17 + luci-app-tailscale-ng/README.md | 110 ++ .../docs/images/tailscale-ng-auth.png | Bin 0 -> 100877 bytes .../docs/images/tailscale-ng-settings.png | Bin 0 -> 149679 bytes .../docs/images/tailscale-ng-status.png | Bin 0 -> 303490 bytes .../resources/view/tailscale-ng/index.js | 817 +++++++++++ luci-app-tailscale-ng/install.sh | 56 + .../root/etc/config/luci-app-tailscale-ng | 29 + .../hotplug.d/iface/40-luci-app-tailscale-ng | 14 + .../root/etc/init.d/luci-app-tailscale-ng | 120 ++ .../etc/uci-defaults/40_luci-app-tailscale-ng | 8 + .../luci/menu.d/luci-app-tailscale-ng.json | 14 + .../rpcd/acl.d/luci-app-tailscale-ng.json | 23 + .../sdd/final-review-fix-report.md | 210 --- .../.superpowers/sdd/task-1-report.md | 36 - .../.superpowers/sdd/task-2-report.md | 35 - .../.superpowers/sdd/task-3-report.md | 38 - .../.superpowers/sdd/task-4-report.md | 37 - luci-app-tailscale/Makefile | 48 +- luci-app-tailscale/README.md | 152 +- ...2026-07-13-tailscale-peer-observability.md | 1154 ---------------- .../2026-07-17-tailscale-openclash-bypass.md | 1103 --------------- .../2026-07-18-openclash-bypass-ui-copy.md | 234 ---- ...026-07-18-tailscale-current-device-list.md | 188 --- ...26-07-23-tailscale-mwan3-policy-routing.md | 18 - .../2026-07-06-adguard-dns-switch-design.md | 202 --- ...-13-tailscale-peer-observability-design.md | 209 --- ...07-16-tailscale-openclash-bypass-design.md | 359 ----- ...6-07-18-openclash-bypass-ui-copy-design.md | 68 - ...18-tailscale-current-device-list-design.md | 27 - ...3-tailscale-mwan3-policy-routing-design.md | 91 -- .../resources/view/tailscale/interface.js | 7 +- .../resources/view/tailscale/log.js | 74 +- .../resources/view/tailscale/peers.js | 695 ---------- .../resources/view/tailscale/setting.js | 872 ++---------- luci-app-tailscale/po/templates/tailscale.pot | 404 +----- luci-app-tailscale/po/zh_Hans/tailscale.po | 397 +----- luci-app-tailscale/po/zh_Hant/tailscale.po | 397 +----- luci-app-tailscale/root/etc/config/tailscale | 35 +- .../root/etc/config/tailscale_openclash | 2 - .../root/etc/config/tailscale_policy_routing | 3 - .../root/etc/hotplug.d/iface/40-tailscale | 8 + .../iface/98-tailscale-policy-routing | 14 - luci-app-tailscale/root/etc/init.d/tailscale | 246 +--- .../etc/init.d/tailscale-openclash-bypass | 25 - .../root/etc/init.d/tailscale-policy-routing | 30 - .../root/etc/uci-defaults/40_luci-tailscale | 31 +- .../lib/upgrade/keep.d/luci-app-tailscale | 1 - .../root/usr/libexec/rpcd/luci.tailscale | 230 ---- .../usr/sbin/tailscale_adguard_dns_switch | 609 -------- .../root/usr/sbin/tailscale_helper | 576 +------- .../root/usr/sbin/tailscale_keepalive | 286 ---- .../root/usr/sbin/tailscale_openclash_bypass | 1077 --------------- .../root/usr/sbin/tailscale_peer_probe | 89 -- .../root/usr/sbin/tailscale_policy_routing | 429 ------ .../root/usr/sbin/tailscale_secrets | 555 -------- .../share/luci/menu.d/luci-app-tailscale.json | 8 - .../share/rpcd/acl.d/luci-app-tailscale.json | 16 +- luci-app-tailscale/tests/log_view_test.js | 40 - .../tests/package_release_test.sh | 789 ----------- .../tests/peers_pagination_test.js | 286 ---- .../tests/release_workflow_test.sh | 80 -- .../tests/setting_openclash_bypass_test.js | 339 ----- .../tests/setting_policy_routing_test.js | 29 - .../tests/setting_preflight_test.js | 306 ----- .../tailscale_adguard_dns_switch_test.sh | 579 -------- .../tailscale_helper_network_cleanup_test.sh | 736 ---------- .../tailscale_init_adguard_lifecycle_test.sh | 476 ------- .../tests/tailscale_keepalive_test.sh | 214 --- ...tailscale_legacy_adguard_migration_test.sh | 85 -- .../tests/tailscale_openclash_bypass_test.sh | 1220 ----------------- .../tests/tailscale_openclash_install_test.sh | 348 ----- .../tailscale_openclash_lifecycle_test.sh | 85 -- .../tests/tailscale_peer_probe_test.sh | 157 --- ...tailscale_policy_routing_lifecycle_test.sh | 79 -- .../tests/tailscale_policy_routing_test.sh | 279 ---- .../tests/tailscale_rpcd_test.sh | 254 ---- .../tests/tailscale_secrets_test.sh | 220 --- 99 files changed, 3036 insertions(+), 17584 deletions(-) create mode 100644 luci-app-substore/tests/vmess_cipher_test.lua create mode 100644 luci-app-tailscale-ng/.deploy/README.md create mode 100644 luci-app-tailscale-ng/.deploy/deploy.sh create mode 100644 luci-app-tailscale-ng/.deploy/mapping.tsv create mode 100644 luci-app-tailscale-ng/.deploy/target.env.example create mode 100644 luci-app-tailscale-ng/.gitattributes create mode 100644 luci-app-tailscale-ng/.gitignore create mode 100644 luci-app-tailscale-ng/LICENSE create mode 100644 luci-app-tailscale-ng/Makefile create mode 100644 luci-app-tailscale-ng/README.md create mode 100644 luci-app-tailscale-ng/docs/images/tailscale-ng-auth.png create mode 100644 luci-app-tailscale-ng/docs/images/tailscale-ng-settings.png create mode 100644 luci-app-tailscale-ng/docs/images/tailscale-ng-status.png create mode 100644 luci-app-tailscale-ng/htdocs/luci-static/resources/view/tailscale-ng/index.js create mode 100644 luci-app-tailscale-ng/install.sh create mode 100644 luci-app-tailscale-ng/root/etc/config/luci-app-tailscale-ng create mode 100755 luci-app-tailscale-ng/root/etc/hotplug.d/iface/40-luci-app-tailscale-ng create mode 100755 luci-app-tailscale-ng/root/etc/init.d/luci-app-tailscale-ng create mode 100755 luci-app-tailscale-ng/root/etc/uci-defaults/40_luci-app-tailscale-ng create mode 100644 luci-app-tailscale-ng/root/usr/share/luci/menu.d/luci-app-tailscale-ng.json create mode 100644 luci-app-tailscale-ng/root/usr/share/rpcd/acl.d/luci-app-tailscale-ng.json delete mode 100644 luci-app-tailscale/.superpowers/sdd/final-review-fix-report.md delete mode 100644 luci-app-tailscale/.superpowers/sdd/task-1-report.md delete mode 100644 luci-app-tailscale/.superpowers/sdd/task-2-report.md delete mode 100644 luci-app-tailscale/.superpowers/sdd/task-3-report.md delete mode 100644 luci-app-tailscale/.superpowers/sdd/task-4-report.md delete mode 100644 luci-app-tailscale/docs/superpowers/plans/2026-07-13-tailscale-peer-observability.md delete mode 100644 luci-app-tailscale/docs/superpowers/plans/2026-07-17-tailscale-openclash-bypass.md delete mode 100644 luci-app-tailscale/docs/superpowers/plans/2026-07-18-openclash-bypass-ui-copy.md delete mode 100644 luci-app-tailscale/docs/superpowers/plans/2026-07-18-tailscale-current-device-list.md delete mode 100644 luci-app-tailscale/docs/superpowers/plans/2026-07-23-tailscale-mwan3-policy-routing.md delete mode 100644 luci-app-tailscale/docs/superpowers/specs/2026-07-06-adguard-dns-switch-design.md delete mode 100644 luci-app-tailscale/docs/superpowers/specs/2026-07-13-tailscale-peer-observability-design.md delete mode 100644 luci-app-tailscale/docs/superpowers/specs/2026-07-16-tailscale-openclash-bypass-design.md delete mode 100644 luci-app-tailscale/docs/superpowers/specs/2026-07-18-openclash-bypass-ui-copy-design.md delete mode 100644 luci-app-tailscale/docs/superpowers/specs/2026-07-18-tailscale-current-device-list-design.md delete mode 100644 luci-app-tailscale/docs/superpowers/specs/2026-07-23-tailscale-mwan3-policy-routing-design.md delete mode 100644 luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js delete mode 100644 luci-app-tailscale/root/etc/config/tailscale_openclash delete mode 100644 luci-app-tailscale/root/etc/config/tailscale_policy_routing create mode 100755 luci-app-tailscale/root/etc/hotplug.d/iface/40-tailscale delete mode 100755 luci-app-tailscale/root/etc/hotplug.d/iface/98-tailscale-policy-routing delete mode 100755 luci-app-tailscale/root/etc/init.d/tailscale-openclash-bypass delete mode 100755 luci-app-tailscale/root/etc/init.d/tailscale-policy-routing delete mode 100644 luci-app-tailscale/root/lib/upgrade/keep.d/luci-app-tailscale delete mode 100755 luci-app-tailscale/root/usr/libexec/rpcd/luci.tailscale delete mode 100755 luci-app-tailscale/root/usr/sbin/tailscale_adguard_dns_switch delete mode 100755 luci-app-tailscale/root/usr/sbin/tailscale_keepalive delete mode 100755 luci-app-tailscale/root/usr/sbin/tailscale_openclash_bypass delete mode 100755 luci-app-tailscale/root/usr/sbin/tailscale_peer_probe delete mode 100755 luci-app-tailscale/root/usr/sbin/tailscale_policy_routing delete mode 100755 luci-app-tailscale/root/usr/sbin/tailscale_secrets delete mode 100644 luci-app-tailscale/tests/log_view_test.js delete mode 100644 luci-app-tailscale/tests/package_release_test.sh delete mode 100644 luci-app-tailscale/tests/peers_pagination_test.js delete mode 100644 luci-app-tailscale/tests/release_workflow_test.sh delete mode 100644 luci-app-tailscale/tests/setting_openclash_bypass_test.js delete mode 100755 luci-app-tailscale/tests/setting_policy_routing_test.js delete mode 100644 luci-app-tailscale/tests/setting_preflight_test.js delete mode 100755 luci-app-tailscale/tests/tailscale_adguard_dns_switch_test.sh delete mode 100755 luci-app-tailscale/tests/tailscale_helper_network_cleanup_test.sh delete mode 100644 luci-app-tailscale/tests/tailscale_init_adguard_lifecycle_test.sh delete mode 100755 luci-app-tailscale/tests/tailscale_keepalive_test.sh delete mode 100755 luci-app-tailscale/tests/tailscale_legacy_adguard_migration_test.sh delete mode 100644 luci-app-tailscale/tests/tailscale_openclash_bypass_test.sh delete mode 100644 luci-app-tailscale/tests/tailscale_openclash_install_test.sh delete mode 100755 luci-app-tailscale/tests/tailscale_openclash_lifecycle_test.sh delete mode 100644 luci-app-tailscale/tests/tailscale_peer_probe_test.sh delete mode 100755 luci-app-tailscale/tests/tailscale_policy_routing_lifecycle_test.sh delete mode 100755 luci-app-tailscale/tests/tailscale_policy_routing_test.sh delete mode 100755 luci-app-tailscale/tests/tailscale_rpcd_test.sh delete mode 100755 luci-app-tailscale/tests/tailscale_secrets_test.sh diff --git a/luci-app-substore/CHANGELOG.md b/luci-app-substore/CHANGELOG.md index 4f3456db4..daeb85924 100644 --- a/luci-app-substore/CHANGELOG.md +++ b/luci-app-substore/CHANGELOG.md @@ -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` 的片段」 diff --git a/luci-app-substore/Makefile b/luci-app-substore/Makefile index 23a704e44..c92ca59a4 100644 --- a/luci-app-substore/Makefile +++ b/luci-app-substore/Makefile @@ -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 diff --git a/luci-app-substore/README.en.md b/luci-app-substore/README.en.md index a04d95c03..29662f107 100644 --- a/luci-app-substore/README.en.md +++ b/luci-app-substore/README.en.md @@ -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**. diff --git a/luci-app-substore/README.md b/luci-app-substore/README.md index 5267881ae..9283ada9d 100644 --- a/luci-app-substore/README.md +++ b/luci-app-substore/README.md @@ -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 菜单打开:**服务 → 订阅**。 diff --git a/luci-app-substore/docs/ARCHITECTURE.md b/luci-app-substore/docs/ARCHITECTURE.md index 845c4a38b..d12b411d5 100644 --- a/luci-app-substore/docs/ARCHITECTURE.md +++ b/luci-app-substore/docs/ARCHITECTURE.md @@ -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 界面:订阅列表、编辑更新、节点查看、规则配置、输出复制、状态日志 diff --git a/luci-app-substore/docs/INSTALL.md b/luci-app-substore/docs/INSTALL.md index 3b220541c..2d0c179d7 100644 --- a/luci-app-substore/docs/INSTALL.md +++ b/luci-app-substore/docs/INSTALL.md @@ -3,18 +3,18 @@ > NOTE: stage-0 skeleton has not yet been installed/verified on a target device. > This document will be updated once installation is validated. > -> 包名中的版本号需与 Makefile 的 `PKG_VERSION` / `PKG_RELEASE` 保持同步(当前 2.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: diff --git a/luci-app-substore/docs/TESTING.md b/luci-app-substore/docs/TESTING.md index 407b70bcd..ab3596b95 100644 --- a/luci-app-substore/docs/TESTING.md +++ b/luci-app-substore/docs/TESTING.md @@ -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` 生成 diff --git a/luci-app-substore/root/usr/share/substore/node.lua b/luci-app-substore/root/usr/share/substore/node.lua index dd74ae805..9571195b8 100644 --- a/luci-app-substore/root/usr/share/substore/node.lua +++ b/luci-app-substore/root/usr/share/substore/node.lua @@ -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 diff --git a/luci-app-substore/root/usr/share/substore/output_singbox.lua b/luci-app-substore/root/usr/share/substore/output_singbox.lua index 4ec53766b..bdc95cc23 100644 --- a/luci-app-substore/root/usr/share/substore/output_singbox.lua +++ b/luci-app-substore/root/usr/share/substore/output_singbox.lua @@ -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 "" diff --git a/luci-app-substore/root/usr/share/substore/output_uri.lua b/luci-app-substore/root/usr/share/substore/output_uri.lua index 57e1fcad1..7db666f87 100644 --- a/luci-app-substore/root/usr/share/substore/output_uri.lua +++ b/luci-app-substore/root/usr/share/substore/output_uri.lua @@ -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 diff --git a/luci-app-substore/root/usr/share/substore/output_v2ray.lua b/luci-app-substore/root/usr/share/substore/output_v2ray.lua index 07b7035cc..9c0bb8e70 100644 --- a/luci-app-substore/root/usr/share/substore/output_v2ray.lua +++ b/luci-app-substore/root/usr/share/substore/output_v2ray.lua @@ -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 diff --git a/luci-app-substore/root/usr/share/substore/parser.lua b/luci-app-substore/root/usr/share/substore/parser.lua index 455dff0ad..05e78779c 100644 --- a/luci-app-substore/root/usr/share/substore/parser.lua +++ b/luci-app-substore/root/usr/share/substore/parser.lua @@ -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), } diff --git a/luci-app-substore/root/usr/share/substore/parser_json_config.lua b/luci-app-substore/root/usr/share/substore/parser_json_config.lua index f280713f1..9c8ae2ce6 100644 --- a/luci-app-substore/root/usr/share/substore/parser_json_config.lua +++ b/luci-app-substore/root/usr/share/substore/parser_json_config.lua @@ -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 diff --git a/luci-app-substore/tests/vmess_cipher_test.lua b/luci-app-substore/tests/vmess_cipher_test.lua new file mode 100644 index 000000000..b75c61c9f --- /dev/null +++ b/luci-app-substore/tests/vmess_cipher_test.lua @@ -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) diff --git a/luci-app-tailscale-ng/.deploy/README.md b/luci-app-tailscale-ng/.deploy/README.md new file mode 100644 index 000000000..4d671d328 --- /dev/null +++ b/luci-app-tailscale-ng/.deploy/README.md @@ -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`) diff --git a/luci-app-tailscale-ng/.deploy/deploy.sh b/luci-app-tailscale-ng/.deploy/deploy.sh new file mode 100644 index 000000000..52df087db --- /dev/null +++ b/luci-app-tailscale-ng/.deploy/deploy.sh @@ -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 "$@" diff --git a/luci-app-tailscale-ng/.deploy/mapping.tsv b/luci-app-tailscale-ng/.deploy/mapping.tsv new file mode 100644 index 000000000..5475f9c5f --- /dev/null +++ b/luci-app-tailscale-ng/.deploy/mapping.tsv @@ -0,0 +1,10 @@ +# local_path_from_deploy_dirremote_absolute_pathmode +# 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 diff --git a/luci-app-tailscale-ng/.deploy/target.env.example b/luci-app-tailscale-ng/.deploy/target.env.example new file mode 100644 index 000000000..f31aceebf --- /dev/null +++ b/luci-app-tailscale-ng/.deploy/target.env.example @@ -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 diff --git a/luci-app-tailscale-ng/.gitattributes b/luci-app-tailscale-ng/.gitattributes new file mode 100644 index 000000000..d1adcc327 --- /dev/null +++ b/luci-app-tailscale-ng/.gitattributes @@ -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 diff --git a/luci-app-tailscale-ng/.gitignore b/luci-app-tailscale-ng/.gitignore new file mode 100644 index 000000000..906716dfe --- /dev/null +++ b/luci-app-tailscale-ng/.gitignore @@ -0,0 +1,3 @@ +.deploy/target.env +.deploy/.ssh/ +.private diff --git a/luci-app-tailscale-ng/LICENSE b/luci-app-tailscale-ng/LICENSE new file mode 100644 index 000000000..f288702d2 --- /dev/null +++ b/luci-app-tailscale-ng/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + 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. + + + Copyright (C) + + 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 . + +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: + + Copyright (C) + 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 +. + + 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 +. diff --git a/luci-app-tailscale-ng/Makefile b/luci-app-tailscale-ng/Makefile new file mode 100644 index 000000000..e2dfc1de3 --- /dev/null +++ b/luci-app-tailscale-ng/Makefile @@ -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 diff --git a/luci-app-tailscale-ng/README.md b/luci-app-tailscale-ng/README.md new file mode 100644 index 000000000..4c02aa570 --- /dev/null +++ b/luci-app-tailscale-ng/README.md @@ -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 + + + + + + + + + + + + +
AuthenticationSettingsStatus
Tailscale NG Authentication tabTailscale NG Settings tabTailscale NG Status tab
+ +## 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) diff --git a/luci-app-tailscale-ng/docs/images/tailscale-ng-auth.png b/luci-app-tailscale-ng/docs/images/tailscale-ng-auth.png new file mode 100644 index 0000000000000000000000000000000000000000..cbc95bf7066617947d8b8eb7ddac6c30fb65240d GIT binary patch literal 100877 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 literal 0 HcmV?d00001 diff --git a/luci-app-tailscale-ng/docs/images/tailscale-ng-status.png b/luci-app-tailscale-ng/docs/images/tailscale-ng-status.png new file mode 100644 index 0000000000000000000000000000000000000000..72d31d25d08114ce615e06af2149f656f580d2e7 GIT binary patch literal 303490 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

YphC1ZY;N$tUvr) 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 z

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 z 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

%dld*oL6O174{+U8-Pk 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�uLr-rc}K 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 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.') + '
' + + '' + _('Use a non-reusable auth key and disable key expiry for trusted machines via the provider admin console.') + ''; + 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. 192.168.1.0/24.'); + 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.'), + '
' + _('Tailscale documentation') + '' + ); + 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; + }); + } +}); diff --git a/luci-app-tailscale-ng/install.sh b/luci-app-tailscale-ng/install.sh new file mode 100644 index 000000000..1c6f4b7d9 --- /dev/null +++ b/luci-app-tailscale-ng/install.sh @@ -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" diff --git a/luci-app-tailscale-ng/root/etc/config/luci-app-tailscale-ng b/luci-app-tailscale-ng/root/etc/config/luci-app-tailscale-ng new file mode 100644 index 000000000..37aec8800 --- /dev/null +++ b/luci-app-tailscale-ng/root/etc/config/luci-app-tailscale-ng @@ -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' diff --git a/luci-app-tailscale-ng/root/etc/hotplug.d/iface/40-luci-app-tailscale-ng b/luci-app-tailscale-ng/root/etc/hotplug.d/iface/40-luci-app-tailscale-ng new file mode 100755 index 000000000..9cd5e4e06 --- /dev/null +++ b/luci-app-tailscale-ng/root/etc/hotplug.d/iface/40-luci-app-tailscale-ng @@ -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 & diff --git a/luci-app-tailscale-ng/root/etc/init.d/luci-app-tailscale-ng b/luci-app-tailscale-ng/root/etc/init.d/luci-app-tailscale-ng new file mode 100755 index 000000000..ecbe81224 --- /dev/null +++ b/luci-app-tailscale-ng/root/etc/init.d/luci-app-tailscale-ng @@ -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 +} diff --git a/luci-app-tailscale-ng/root/etc/uci-defaults/40_luci-app-tailscale-ng b/luci-app-tailscale-ng/root/etc/uci-defaults/40_luci-app-tailscale-ng new file mode 100755 index 000000000..09fc80aa3 --- /dev/null +++ b/luci-app-tailscale-ng/root/etc/uci-defaults/40_luci-app-tailscale-ng @@ -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 diff --git a/luci-app-tailscale-ng/root/usr/share/luci/menu.d/luci-app-tailscale-ng.json b/luci-app-tailscale-ng/root/usr/share/luci/menu.d/luci-app-tailscale-ng.json new file mode 100644 index 000000000..07c853c51 --- /dev/null +++ b/luci-app-tailscale-ng/root/usr/share/luci/menu.d/luci-app-tailscale-ng.json @@ -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 } + } + } +} diff --git a/luci-app-tailscale-ng/root/usr/share/rpcd/acl.d/luci-app-tailscale-ng.json b/luci-app-tailscale-ng/root/usr/share/rpcd/acl.d/luci-app-tailscale-ng.json new file mode 100644 index 000000000..327a9dcc6 --- /dev/null +++ b/luci-app-tailscale-ng/root/usr/share/rpcd/acl.d/luci-app-tailscale-ng.json @@ -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" ] + } + } +} diff --git a/luci-app-tailscale/.superpowers/sdd/final-review-fix-report.md b/luci-app-tailscale/.superpowers/sdd/final-review-fix-report.md deleted file mode 100644 index b8f478fed..000000000 --- a/luci-app-tailscale/.superpowers/sdd/final-review-fix-report.md +++ /dev/null @@ -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 ``; 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: /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: /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`. diff --git a/luci-app-tailscale/.superpowers/sdd/task-1-report.md b/luci-app-tailscale/.superpowers/sdd/task-1-report.md deleted file mode 100644 index 363fb32de..000000000 --- a/luci-app-tailscale/.superpowers/sdd/task-1-report.md +++ /dev/null @@ -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` diff --git a/luci-app-tailscale/.superpowers/sdd/task-2-report.md b/luci-app-tailscale/.superpowers/sdd/task-2-report.md deleted file mode 100644 index 56a0781eb..000000000 --- a/luci-app-tailscale/.superpowers/sdd/task-2-report.md +++ /dev/null @@ -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` diff --git a/luci-app-tailscale/.superpowers/sdd/task-3-report.md b/luci-app-tailscale/.superpowers/sdd/task-3-report.md deleted file mode 100644 index a9ee7542d..000000000 --- a/luci-app-tailscale/.superpowers/sdd/task-3-report.md +++ /dev/null @@ -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: }` 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`. diff --git a/luci-app-tailscale/.superpowers/sdd/task-4-report.md b/luci-app-tailscale/.superpowers/sdd/task-4-report.md deleted file mode 100644 index 6f5c77f79..000000000 --- a/luci-app-tailscale/.superpowers/sdd/task-4-report.md +++ /dev/null @@ -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. diff --git a/luci-app-tailscale/Makefile b/luci-app-tailscale/Makefile index ad51f721d..f5cf6db9d 100644 --- a/luci-app-tailscale/Makefile +++ b/luci-app-tailscale/Makefile @@ -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 \ No newline at end of file diff --git a/luci-app-tailscale/README.md b/luci-app-tailscale/README.md index 88822c3bb..50f4cbde6 100644 --- a/luci-app-tailscale/README.md +++ b/luci-app-tailscale/README.md @@ -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 -Basic -Advanced -Extra diff --git a/luci-app-tailscale/docs/superpowers/plans/2026-07-13-tailscale-peer-observability.md b/luci-app-tailscale/docs/superpowers/plans/2026-07-13-tailscale-peer-observability.md deleted file mode 100644 index 71ea35111..000000000 --- a/luci-app-tailscale/docs/superpowers/plans/2026-07-13-tailscale-peer-observability.md +++ /dev/null @@ -1,1154 +0,0 @@ -# Tailscale Peer Observability 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:** Add a read-only LuCI page that lists all Tailscale peers and lets the user manually probe whether each peer is reached directly or through DERP. - -**Architecture:** Add a focused shell helper for safe `tailscale ping` probing and a new LuCI view for rendering peer state from `tailscale status --json`. Keep the existing settings, keepalive, DNS, firewall, and network lifecycle behavior unchanged. - -**Tech Stack:** OpenWrt LuCI JavaScript views, `fs.exec()`, rpcd ACL JSON, POSIX shell helpers, package static shell tests. - -## Global Constraints - -- The feature is diagnostic only; it must not change Tailscale configuration, keepalive configuration, firewall rules, DNS settings, or network interfaces. -- Add menu path `admin/vpn/tailscale/peers` with title `Peers`, Chinese title `对端列表`, after `Interface Info` and before `Logs`. -- Page load must not automatically ping all peers. -- The first version must not include a `Probe all` button. -- Probe helper path must be `/usr/sbin/tailscale_peer_probe`. -- Probe helper accepts exactly one peer argument. -- Probe helper validates peer arguments: length 1 to 253 characters, allowed characters letters, numbers, `_`, `-`, `.`, `:`. -- Probe helper outputs JSON only. -- LuCI probe action must be read-only. -- No network reload, Tailscale restart, or firewall restart is part of this feature. - ---- - -## File Structure - -- Create `root/usr/sbin/tailscale_peer_probe`: safe command wrapper around `/usr/sbin/tailscale ping`, returning normalized JSON. -- Create `tests/tailscale_peer_probe_test.sh`: shell unit tests with a fake `tailscale` binary. -- Create `htdocs/luci-static/resources/view/tailscale/peers.js`: new read-only peer observability page. -- Modify `root/usr/share/luci/menu.d/luci-app-tailscale.json`: add `admin/vpn/tailscale/peers`. -- Modify `root/usr/share/rpcd/acl.d/luci-app-tailscale.json`: allow read-only exec of `/usr/sbin/tailscale_peer_probe`. -- Modify `tests/package_release_test.sh`: assert new files, menu, ACL, strings, and syntax coverage. -- Modify `po/templates/tailscale.pot`, `po/zh_Hans/tailscale.po`, and `po/zh_Hant/tailscale.po`: add visible labels for the new page. - ---- - -### Task 1: Probe Helper - -**Files:** -- Create: `root/usr/sbin/tailscale_peer_probe` -- Create: `tests/tailscale_peer_probe_test.sh` -- Modify: `tests/package_release_test.sh` - -**Interfaces:** -- Consumes: one CLI argument `peer`. -- Produces: JSON object with fields `peer`, `ok`, `path`, `latency_ms`, `relay`, `summary`, `raw`. - -- [ ] **Step 1: Add failing package assertions** - -Add these assertions to `tests/package_release_test.sh` after the existing helper file assertions: - -```sh -assert_file root/usr/sbin/tailscale_peer_probe -assert_contains "tailscale_peer_probe" root/usr/share/rpcd/acl.d/luci-app-tailscale.json -assert_contains "Peer probe" root/usr/sbin/tailscale_peer_probe -``` - -- [ ] **Step 2: Add the failing helper test** - -Create `tests/tailscale_peer_probe_test.sh`: - -```sh -#!/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 -} - -mkdir -p "$TMP_DIR" - -cat >"$TMP_DIR/fake-tailscale" <<'SH' -#!/bin/sh -case "$*" in - "ping --c=1 --timeout=5s 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=5s derp-peer") - echo "pong from derp-peer (100.64.2.1) via DERP(litata) in 41.8ms" - exit 0 - ;; - "ping --c=1 --timeout=5s weird-peer") - echo "pong from weird-peer using new format" - exit 0 - ;; - "ping --c=1 --timeout=5s failed-peer") - echo "timeout waiting for pong from failed-peer" >&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_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_contains '"peer":"derp-peer"' "$derp_output" -assert_contains '"ok":true' "$derp_output" -assert_contains '"path":"derp"' "$derp_output" -assert_contains '"relay":"litata"' "$derp_output" -assert_contains '"latency_ms":41.8' "$derp_output" -assert_contains '"summary":"DERP litata 41.8 ms"' "$derp_output" - -unknown_output="$(run_probe weird-peer)" -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" - -failed_output="$(run_probe failed-peer || true)" -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_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_contains '"ok":false' "$missing_arg_output" -assert_contains '"summary":"usage: tailscale_peer_probe "' "$missing_arg_output" - -echo "tailscale_peer_probe tests passed" -``` - -- [ ] **Step 3: Run tests to verify RED** - -Run: - -```sh -sh tests/package_release_test.sh -sh tests/tailscale_peer_probe_test.sh -``` - -Expected: - -- `tests/package_release_test.sh` fails because `root/usr/sbin/tailscale_peer_probe` is missing. -- `tests/tailscale_peer_probe_test.sh` fails because the helper is missing or not executable. - -- [ ] **Step 4: Implement `tailscale_peer_probe`** - -Create `root/usr/sbin/tailscale_peer_probe`: - -```sh -#!/bin/sh - -# Peer probe for LuCI Tailscale diagnostics. - -TAILSCALE_BIN="${TAILSCALE_BIN:-/usr/sbin/tailscale}" - -json_escape() { - printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g; s/ /\\t/g' -} - -json_string() { - printf '"%s"' "$(json_escape "$1")" -} - -emit_json() { - peer="$1" - ok="$2" - path="$3" - latency_ms="$4" - relay="$5" - summary="$6" - raw="$7" - - printf '{' - printf '"peer":'; json_string "$peer"; printf ',' - printf '"ok":%s,' "$ok" - printf '"path":'; json_string "$path"; printf ',' - if [ -n "$latency_ms" ]; then - printf '"latency_ms":%s,' "$latency_ms" - else - printf '"latency_ms":null,' - fi - printf '"relay":'; json_string "$relay"; printf ',' - printf '"summary":'; json_string "$summary"; printf ',' - printf '"raw":'; json_string "$raw" - printf '}\n' -} - -usage() { - emit_json "" false failed "" "" "usage: tailscale_peer_probe " "" - 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=5s "$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)" - -if [ "$rc" -ne 0 ]; then - emit_json "$peer" false failed "" "" "tailscale ping failed" "$raw" - exit 0 -fi - -if [ -n "$relay" ]; then - if [ -n "$latency_ms" ]; then - summary="DERP $relay $latency_ms ms" - else - summary="DERP $relay" - fi - emit_json "$peer" true derp "$latency_ms" "$relay" "$summary" "$raw" - exit 0 -fi - -case "$raw" in - *pong\ from*) - if [ -n "$latency_ms" ]; then - summary="direct $latency_ms ms" - else - summary="direct" - fi - emit_json "$peer" true direct "$latency_ms" "" "$summary" "$raw" - ;; - *) - emit_json "$peer" true unknown "" "" "unknown path" "$raw" - ;; -esac -``` - -Run: - -```sh -chmod +x root/usr/sbin/tailscale_peer_probe -``` - -- [ ] **Step 5: Run helper tests to verify GREEN** - -Run: - -```sh -sh tests/tailscale_peer_probe_test.sh -sh tests/package_release_test.sh -``` - -Expected: - -- `tailscale_peer_probe tests passed` -- `package release tests passed` after ACL/menu assertions are added in later tasks; at this step it may still fail on menu/ACL checks not yet implemented. - -Commit after Task 1 only if helper tests pass: - -```sh -git add root/usr/sbin/tailscale_peer_probe tests/tailscale_peer_probe_test.sh tests/package_release_test.sh -git commit -m "Add Tailscale peer probe helper" -``` - ---- - -### Task 2: Menu and ACL - -**Files:** -- Modify: `root/usr/share/luci/menu.d/luci-app-tailscale.json` -- Modify: `root/usr/share/rpcd/acl.d/luci-app-tailscale.json` -- Modify: `tests/package_release_test.sh` - -**Interfaces:** -- Consumes: helper `/usr/sbin/tailscale_peer_probe` from Task 1. -- Produces: LuCI route `admin/vpn/tailscale/peers`, ACL exec access for the helper. - -- [ ] **Step 1: Add failing static assertions** - -Add to `tests/package_release_test.sh` near menu and ACL checks: - -```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 '"/usr/sbin/tailscale_peer_probe": [ "exec" ]' root/usr/share/rpcd/acl.d/luci-app-tailscale.json -``` - -- [ ] **Step 2: Run test to verify RED** - -Run: - -```sh -sh tests/package_release_test.sh -``` - -Expected: FAIL because the menu and ACL entries are not present. - -- [ ] **Step 3: Add menu entry** - -Edit `root/usr/share/luci/menu.d/luci-app-tailscale.json` so the child routes are: - -```json - "admin/vpn/tailscale/interface": { - "title": "Interface Info", - "order": 20, - "action": { - "type": "view", - "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, - "action": { - "type": "view", - "path": "tailscale/log" - } - } -``` - -- [ ] **Step 4: Add ACL entry** - -Edit `root/usr/share/rpcd/acl.d/luci-app-tailscale.json` read file exec list to include: - -```json - "/usr/sbin/tailscale_peer_probe": [ "exec" ], -``` - -Keep it in the read section, not write. - -- [ ] **Step 5: Run static test to verify GREEN** - -Run: - -```sh -sh tests/package_release_test.sh -``` - -Expected: PASS for menu and ACL assertions, unless later page assertions are not yet added. - -Commit: - -```sh -git add root/usr/share/luci/menu.d/luci-app-tailscale.json root/usr/share/rpcd/acl.d/luci-app-tailscale.json tests/package_release_test.sh -git commit -m "Add Tailscale peers menu and ACL" -``` - ---- - -### Task 3: Peers LuCI View - -**Files:** -- Create: `htdocs/luci-static/resources/view/tailscale/peers.js` -- Modify: `tests/package_release_test.sh` - -**Interfaces:** -- Consumes: `tailscale status --json`, `/usr/sbin/tailscale_peer_probe`. -- Produces: a read-only LuCI page with peer table, filters, and per-peer probe action. - -- [ ] **Step 1: Add failing static assertions** - -Add to `tests/package_release_test.sh` after existing view file assertions: - -```sh -assert_file htdocs/luci-static/resources/view/tailscale/peers.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 "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 -``` - -- [ ] **Step 2: Run test to verify RED** - -Run: - -```sh -sh tests/package_release_test.sh -``` - -Expected: FAIL because `peers.js` is missing. - -- [ ] **Step 3: Create `peers.js`** - -Create `htdocs/luci-static/resources/view/tailscale/peers.js`: - -```javascript -/* SPDX-License-Identifier: GPL-3.0-only - * - * Copyright (C) 2026 - */ - -'use strict'; -'require dom'; -'require fs'; -'require poll'; -'require ui'; -'require view'; - -function parseStatus(stdout) { - const parsed = JSON.parse(stdout.replace(/("\w+"):\s*(\d+)/g, '$1:"$2"')); - const peers = Object.values(parsed.Peer || {}).map(function(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 routes = Array.isArray(peer.PrimaryRoutes) ? peer.PrimaryRoutes : []; - const name = shortDnsName || hostName || dnsName || ip; - - return { - name: name, - hostName: hostName, - dnsName: dnsName, - ip: ip, - online: !!peer.Online, - lastSeen: peer.LastSeen || '', - exitNode: !!peer.ExitNodeOption, - routes: routes, - hasSubnetRoutes: routes.length > 0 - }; - }).filter(function(peer) { - return !!peer.name; - }); - - peers.sort(function(a, b) { - return a.name.localeCompare(b.name); - }); - - return peers; -} - -function renderBadge(text, className) { - return E('span', { - class: className || 'label', - style: 'display:inline-block;margin-right:4px;padding:1px 6px;border-radius:3px;background:#eef2f7;color:#334155;font-size:12px' - }, text); -} - -function renderProbeResult(result) { - if (!result) - return E('span', { style: 'color:#94a3b8' }, _('Not probed')); - - const path = result.path || 'unknown'; - const color = path === 'direct' ? 'green' : (path === 'derp' ? '#b7791f' : (path === 'failed' ? 'red' : '#64748b')); - const label = path === 'direct' ? _('Direct') : (path === 'derp' ? _('DERP') : (path === 'failed' ? _('Failed') : _('Unknown'))); - const summary = result.summary ? ' - ' + result.summary : ''; - - return E('span', { style: 'color:%s'.format(color) }, label + summary); -} - -return view.extend({ - async load() { - const res = await fs.exec('/usr/sbin/tailscale', ['status', '--json']); - if (res.code !== 0 || !res.stdout || res.stdout.trim() === '') { - ui.addNotification(null, E('p', {}, _('Unable to get Tailscale peer status: %s.').format(res.message || res.stderr || ''))); - return []; - } - - try { - return parseStatus(res.stdout); - } catch (e) { - ui.addNotification(null, E('p', {}, _('Error parsing Tailscale peer status: %s.').format(e.message))); - return []; - } - }, - - filterPeers(peers, filterMode) { - if (filterMode === 'online') - return peers.filter(function(peer) { return peer.online; }); - if (filterMode === 'subnets') - return peers.filter(function(peer) { return peer.hasSubnetRoutes; }); - return peers; - }, - - async probePeer(peerName, button, resultNode) { - button.disabled = true; - dom.content(resultNode, E('span', {}, _('Probing...'))); - - try { - const res = await fs.exec('/usr/sbin/tailscale_peer_probe', [peerName]); - const data = JSON.parse(res.stdout || '{}'); - dom.content(resultNode, renderProbeResult(data)); - } catch (e) { - dom.content(resultNode, renderProbeResult({ - path: 'failed', - summary: String(e.message || e) - })); - } finally { - button.disabled = false; - } - }, - - renderRows(peers, filterMode) { - const filtered = this.filterPeers(peers, filterMode); - - if (!filtered.length) - return E('tr', { class: 'tr' }, E('td', { class: 'td left', colspan: '7' }, _('No peers found'))); - - return filtered.map(function(peer) { - const resultNode = E('span', {}, renderProbeResult(null)); - const button = E('button', { - class: 'btn cbi-button cbi-button-action', - click: ui.createHandlerFn(this, function() { - return this.probePeer(peer.name, button, resultNode); - }) - }, _('Probe')); - - const role = []; - if (peer.exitNode) - role.push(renderBadge(_('Exit node'))); - if (peer.hasSubnetRoutes) - role.push(renderBadge(_('Advertising subnets'))); - - return E('tr', { class: 'tr' }, [ - E('td', { class: 'td left' }, peer.name), - E('td', { class: 'td left' }, peer.ip || '-'), - E('td', { class: 'td left' }, peer.online ? _('Online') : _('Offline')), - E('td', { class: 'td left' }, peer.lastSeen || '-'), - E('td', { class: 'td left' }, role.length ? role : '-'), - E('td', { class: 'td left' }, peer.routes.length ? peer.routes.join(', ') : '-'), - E('td', { class: 'td left' }, [button, E('span', { style: 'margin-left:8px' }, resultNode)]) - ]); - }, this); - }, - - renderContent(peers) { - let filterMode = 'all'; - const rowsBody = E('tbody'); - - const updateRows = L.bind(function() { - dom.content(rowsBody, this.renderRows(peers, filterMode)); - }, this); - - const filterSelect = E('select', { - change: function(ev) { - filterMode = ev.target.value; - updateRows(); - } - }, [ - E('option', { value: 'all' }, _('All')), - E('option', { value: 'online' }, _('Online')), - E('option', { value: 'subnets' }, _('Advertising subnets')) - ]); - - updateRows(); - - return E('div', {}, [ - E('div', { style: 'margin-bottom:10px' }, [ - E('label', { style: 'margin-right:8px' }, _('Filter')), - filterSelect - ]), - E('table', { class: 'table' }, [ - 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')) - ])), - rowsBody - ]) - ]); - }, - - pollData(container) { - poll.add(async () => { - const data = await this.load(); - dom.content(container, this.renderContent(data)); - }); - }, - - render(data) { - const content = E([], [ - 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.')), - E('div') - ]); - const container = content.lastElementChild; - - dom.content(container, this.renderContent(data)); - this.pollData(container); - - return content; - }, - - handleSaveApply: null, - handleSave: null, - handleReset: null -}); -``` - -- [ ] **Step 4: Run JavaScript syntax check** - -Run: - -```sh -node --check htdocs/luci-static/resources/view/tailscale/peers.js -``` - -Expected: exit 0. - -- [ ] **Step 5: Run package test** - -Run: - -```sh -sh tests/package_release_test.sh -``` - -Expected: PASS for new page assertions after translation assertions are added in Task 4. - -Commit: - -```sh -git add htdocs/luci-static/resources/view/tailscale/peers.js tests/package_release_test.sh -git commit -m "Add Tailscale peers LuCI view" -``` - ---- - -### Task 4: Translations and Package Verification - -**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: visible strings from `peers.js`. -- Produces: Chinese labels for the peer observability page. - -- [ ] **Step 1: Add failing translation assertions** - -Add to `tests/package_release_test.sh` near other translation assertions: - -```sh -assert_contains 'msgid "Peers"' po/zh_Hans/tailscale.po -assert_contains 'msgstr "对端列表"' po/zh_Hans/tailscale.po -assert_contains 'msgid "Tailscale Peers"' po/zh_Hans/tailscale.po -assert_contains 'msgstr "Tailscale 对端"' po/zh_Hans/tailscale.po -assert_contains 'msgid "Direct"' po/zh_Hans/tailscale.po -assert_contains 'msgstr "直连"' po/zh_Hans/tailscale.po -assert_contains 'msgid "DERP"' po/zh_Hans/tailscale.po -assert_contains 'msgstr "DERP 中继"' po/zh_Hans/tailscale.po -assert_contains 'msgid "Advertising subnets"' po/zh_Hans/tailscale.po -assert_contains 'msgstr "发布网段"' po/zh_Hans/tailscale.po -``` - -- [ ] **Step 2: Run test to verify RED** - -Run: - -```sh -sh tests/package_release_test.sh -``` - -Expected: FAIL because translation entries are missing. - -- [ ] **Step 3: Add template entries** - -Append these entries to `po/templates/tailscale.pot` if the project does not regenerate POT automatically: - -```po -msgid "Peers" -msgstr "" - -msgid "Tailscale Peers" -msgstr "" - -msgid "View all peers and manually probe whether traffic is direct or relayed through DERP." -msgstr "" - -msgid "Unable to get Tailscale peer status: %s." -msgstr "" - -msgid "Error parsing Tailscale peer status: %s." -msgstr "" - -msgid "No peers found" -msgstr "" - -msgid "Not probed" -msgstr "" - -msgid "Probing..." -msgstr "" - -msgid "Probe" -msgstr "" - -msgid "Direct" -msgstr "" - -msgid "DERP" -msgstr "" - -msgid "Failed" -msgstr "" - -msgid "Unknown" -msgstr "" - -msgid "Filter" -msgstr "" - -msgid "All" -msgstr "" - -msgid "Tailnet IP" -msgstr "" - -msgid "Last Seen" -msgstr "" - -msgid "Role" -msgstr "" - -msgid "Exit node" -msgstr "" - -msgid "Advertising subnets" -msgstr "" - -msgid "Advertised Subnets" -msgstr "" -``` - -- [ ] **Step 4: Add Simplified Chinese entries** - -Append to `po/zh_Hans/tailscale.po`: - -```po -msgid "Peers" -msgstr "对端列表" - -msgid "Tailscale Peers" -msgstr "Tailscale 对端" - -msgid "View all peers and manually probe whether traffic is direct or relayed through DERP." -msgstr "查看全部对端,并手动探测流量是直连还是通过 DERP 中继。" - -msgid "Unable to get Tailscale peer status: %s." -msgstr "无法获取 Tailscale 对端状态:%s。" - -msgid "Error parsing Tailscale peer status: %s." -msgstr "解析 Tailscale 对端状态失败:%s。" - -msgid "No peers found" -msgstr "没有找到对端" - -msgid "Not probed" -msgstr "未探测" - -msgid "Probing..." -msgstr "探测中..." - -msgid "Probe" -msgstr "探测" - -msgid "Direct" -msgstr "直连" - -msgid "DERP" -msgstr "DERP 中继" - -msgid "Failed" -msgstr "失败" - -msgid "Unknown" -msgstr "未知" - -msgid "Filter" -msgstr "筛选" - -msgid "All" -msgstr "全部" - -msgid "Tailnet IP" -msgstr "Tailnet IP" - -msgid "Last Seen" -msgstr "最后在线" - -msgid "Role" -msgstr "角色" - -msgid "Exit node" -msgstr "出口节点" - -msgid "Advertising subnets" -msgstr "发布网段" - -msgid "Advertised Subnets" -msgstr "已发布网段" -``` - -- [ ] **Step 5: Add Traditional Chinese entries** - -Append to `po/zh_Hant/tailscale.po`: - -```po -msgid "Peers" -msgstr "對端列表" - -msgid "Tailscale Peers" -msgstr "Tailscale 對端" - -msgid "View all peers and manually probe whether traffic is direct or relayed through DERP." -msgstr "查看全部對端,並手動探測流量是直連還是透過 DERP 中繼。" - -msgid "Unable to get Tailscale peer status: %s." -msgstr "無法取得 Tailscale 對端狀態:%s。" - -msgid "Error parsing Tailscale peer status: %s." -msgstr "解析 Tailscale 對端狀態失敗:%s。" - -msgid "No peers found" -msgstr "沒有找到對端" - -msgid "Not probed" -msgstr "未探測" - -msgid "Probing..." -msgstr "探測中..." - -msgid "Probe" -msgstr "探測" - -msgid "Direct" -msgstr "直連" - -msgid "DERP" -msgstr "DERP 中繼" - -msgid "Failed" -msgstr "失敗" - -msgid "Unknown" -msgstr "未知" - -msgid "Filter" -msgstr "篩選" - -msgid "All" -msgstr "全部" - -msgid "Tailnet IP" -msgstr "Tailnet IP" - -msgid "Last Seen" -msgstr "最後在線" - -msgid "Role" -msgstr "角色" - -msgid "Exit node" -msgstr "出口節點" - -msgid "Advertising subnets" -msgstr "發布網段" - -msgid "Advertised Subnets" -msgstr "已發布網段" -``` - -- [ ] **Step 6: Run package test** - -Run: - -```sh -sh tests/package_release_test.sh -``` - -Expected: `package release tests passed`. - -Commit: - -```sh -git add po/templates/tailscale.pot po/zh_Hans/tailscale.po po/zh_Hant/tailscale.po tests/package_release_test.sh -git commit -m "Add peer observability translations" -``` - ---- - -### Task 5: Full Verification - -**Files:** -- Test only. - -**Interfaces:** -- Consumes: all files from Tasks 1-4. -- Produces: verified branch ready for PR review. - -- [ ] **Step 1: Run all shell tests** - -Run: - -```sh -sh tests/package_release_test.sh -sh tests/tailscale_peer_probe_test.sh -sh tests/tailscale_adguard_dns_switch_test.sh -sh tests/tailscale_helper_network_cleanup_test.sh -sh tests/tailscale_keepalive_test.sh -``` - -Expected: - -```text -package release tests passed -tailscale_peer_probe tests passed -tailscale_adguard_dns_switch tests passed -tailscale_helper network cleanup tests passed -tailscale_keepalive resolver tests passed -``` - -- [ ] **Step 2: Run JavaScript syntax checks** - -Run: - -```sh -node --check htdocs/luci-static/resources/view/tailscale/setting.js -node --check htdocs/luci-static/resources/view/tailscale/interface.js -node --check htdocs/luci-static/resources/view/tailscale/log.js -node --check htdocs/luci-static/resources/view/tailscale/peers.js -``` - -Expected: all commands exit 0. - -- [ ] **Step 3: Run shell syntax checks** - -Run: - -```sh -find root -type f \( -path '*/init.d/*' -o -path '*/sbin/*' -o -path '*/uci-defaults/*' \) -exec sh -n {} \; -``` - -Expected: exit 0. - -- [ ] **Step 4: Run whitespace check** - -Run: - -```sh -git diff --check -``` - -Expected: exit 0. - -- [ ] **Step 5: Inspect final diff** - -Run: - -```sh -git diff --stat origin/main...HEAD -git diff --name-only origin/main...HEAD -``` - -Expected: changed files include the helper, peer view, menu, ACL, translations, and tests. No unrelated OpenWrt runtime config files should be changed. - -- [ ] **Step 6: Push** - -Run: - -```sh -git push -``` - -Expected: current PR branch updates successfully. - ---- - -### Task 6: Office OpenWrt Hot Deployment Check - -**Files:** -- Remote deployment only after user confirms `ok` for OpenWrt write operations. - -**Interfaces:** -- Consumes: verified files from Tasks 1-5. -- Produces: office OpenWrt can load `Peers` page and probe one peer. - -- [ ] **Step 1: Read infrastructure status before remote operations** - -Run from `/Users/jearton/projects/litata/infra-docs`: - -```sh -sed -n '1,260p' docs/infra/litata-current-infrastructure-status.md -``` - -Expected: read current OpenWrt, Headscale, DNS, and route-sync context. - -- [ ] **Step 2: Confirm write plan with user** - -Ask the user to approve this exact write plan: - -```text -1. Backup current LuCI view/menu/ACL/helper files under /tmp. -2. Upload peers.js, tailscale_peer_probe, menu JSON, and ACL JSON. -3. Do not reload network, do not restart Tailscale, do not restart firewall. -4. Restart rpcd only if LuCI ACL does not pick up the new helper permission. -5. Verify static file over HTTP and run tailscale_peer_probe against one known peer. -6. Rollback by restoring /tmp backups. -``` - -Do not run remote write commands until the user replies `ok`. - -- [ ] **Step 3: Backup remote files** - -After user approval, run: - -```sh -/usr/bin/expect <<'EXPECT' -set timeout 60 -set password {password} -spawn ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null root@192.168.100.1 "set -eu; ts=\$(date +%Y%m%d-%H%M%S); for f in /www/luci-static/resources/view/tailscale/peers.js /usr/share/luci/menu.d/luci-app-tailscale.json /usr/share/rpcd/acl.d/luci-app-tailscale.json /usr/sbin/tailscale_peer_probe; do [ -e \$f ] && cp \$f /tmp/\$(basename \$f).bak-\$ts || true; done; echo BACKUP_TS=\$ts" -expect { - -re "password:" { send "$password\r"; exp_continue } - eof -} -EXPECT -``` - -Expected: prints `BACKUP_TS=...`. - -- [ ] **Step 4: Upload files** - -Run `scp -O` for: - -```text -htdocs/luci-static/resources/view/tailscale/peers.js -> root@192.168.100.1:/www/luci-static/resources/view/tailscale/peers.js -root/usr/sbin/tailscale_peer_probe -> root@192.168.100.1:/usr/sbin/tailscale_peer_probe -root/usr/share/luci/menu.d/luci-app-tailscale.json -> root@192.168.100.1:/usr/share/luci/menu.d/luci-app-tailscale.json -root/usr/share/rpcd/acl.d/luci-app-tailscale.json -> root@192.168.100.1:/usr/share/rpcd/acl.d/luci-app-tailscale.json -``` - -Then run: - -```sh -ssh root@192.168.100.1 "chmod 0755 /usr/sbin/tailscale_peer_probe; chmod 0644 /www/luci-static/resources/view/tailscale/peers.js /usr/share/luci/menu.d/luci-app-tailscale.json /usr/share/rpcd/acl.d/luci-app-tailscale.json" -``` - -- [ ] **Step 5: Verify served static view** - -Run: - -```sh -curl -fsS --max-time 10 http://192.168.100.1/luci-static/resources/view/tailscale/peers.js | rg -n "Tailscale Peers|tailscale_peer_probe|Probe|DERP|Direct" -``` - -Expected: all strings are present. - -- [ ] **Step 6: Verify helper with known peer** - -Run: - -```sh -ssh root@192.168.100.1 "/usr/sbin/tailscale_peer_probe hzsls-openwrt" -``` - -Expected: JSON with `"path":"direct"`, `"path":"derp"`, `"path":"unknown"`, or `"path":"failed"`. The command must output JSON, not shell errors. - -- [ ] **Step 7: Verify LuCI menu visibility** - -Open: - -```text -http://192.168.100.1/cgi-bin/luci/admin/vpn/tailscale/peers -``` - -Expected: page loads and shows the peer table. - -If LuCI returns an ACL or not-found error, restart only rpcd: - -```sh -/etc/init.d/rpcd restart -``` - -Then repeat Step 7. - ---- - -## Self-Review - -- Spec coverage: all spec requirements are mapped to Tasks 1-6. -- Placeholder scan: no unfinished placeholder markers are intentionally left. -- Type consistency: helper JSON fields are consistent across helper, tests, and LuCI view. -- Scope check: no `Probe all`, no keepalive changes, no Headscale API integration, no historical charts. diff --git a/luci-app-tailscale/docs/superpowers/plans/2026-07-17-tailscale-openclash-bypass.md b/luci-app-tailscale/docs/superpowers/plans/2026-07-17-tailscale-openclash-bypass.md deleted file mode 100644 index d12786dbc..000000000 --- a/luci-app-tailscale/docs/superpowers/plans/2026-07-17-tailscale-openclash-bypass.md +++ /dev/null @@ -1,1103 +0,0 @@ -# Tailscale OpenClash Bypass 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:** Add a package-owned, firewall4/nftables-only OpenClash bypass that is applied through OpenClash's official custom firewall hook, while preserving the existing WAN-direct and site-to-site SNAT behavior as independent flows. - -**Architecture:** Keep the existing WAN-direct and Tailscale firewall-zone logic in `tailscale_helper`; the new `/usr/sbin/tailscale_openclash_bypass` helper owns only one marked hook block and four runtime nftables rules. Store the OpenClash toggle in the app-owned `/etc/config/tailscale_openclash` package and give it a dedicated procd reload trigger, because the existing `tailscale` trigger is package-wide and would otherwise restart Tailscale and execute the WAN/firewall path for an OpenClash-only change. The setting remains visible in the existing Tailscale LuCI page, while status is loaded asynchronously through a read-only rpcd method. - -**Tech Stack:** OpenWrt 24.10 firewall4, nftables JSON output, POSIX `ash`, `flock`, `jq`, UCI/procd, LuCI JavaScript, shell and Node.js behavior tests. - -## Approved Final-Review Corrections - -The user approved these corrections on 2026-07-17 after read-only verification -against the office OpenWrt 24.10.5 target. They supersede conflicting details in -Tasks 1-6 below: - -- BusyBox 1.36 `grep` on the target has no `-b`; hook parsing must use exact - whole marker lines and BusyBox-compatible `awk`/POSIX tools while preserving - all bytes outside the managed block. -- The canonical managed body must be validated and repaired. A user line that - merely contains marker text is not a managed marker. -- The procd lifecycle invokes one helper `sync` operation. `sync` re-reads UCI - and reconciles hook plus runtime rules under one lock; it does not call - `reconcile-hook` and `apply` as separate transactions. -- Unsupported or absent OpenClash/firewall4 states are side-effect-free. Missing - OpenClash chains may install the canonical hook and report `waiting`, allowing - the next official OpenClash hook execution to apply rules. -- Remove the custom package `postinst`. Standard LuCI/OpenWrt package lifecycle - must retain LuCI cache invalidation, `rpcd` reload, and normal init handling. -- Normalize UCI booleans with OpenWrt semantics. -- Site-to-site no-SNAT creates/maintains `firewall.tszone.masq='0'` even when no - forwarding access tokens are selected; forwarding sections still depend only - on the access list. -- Cleanup treats an absent fw4 table/chains as success, refreshes state after - chain-read races, restores owner before mode, and removes tracked temporary - files on signals. - -## Global Constraints - -- Preserve every pre-existing working-tree change; do not run `git reset`, `git checkout`, `git restore`, or overwrite files modified by another session. -- Before each task, run `git status --short --branch` and stage only the paths named by that task. -- WAN direct owns only firewall UCI objects and firewall4 reload behavior. -- OpenClash bypass must not read or write firewall UCI, modify `/etc/config/firewall`, reload or restart firewall4, or manage the OpenClash service. -- OpenClash bypass must be invoked by `/etc/openclash/custom/openclash_custom_firewall_rules.sh` after OpenClash creates its chains. -- Support only firewall4/nftables; do not add firewall3 or iptables compatibility. -- Use test-driven development: add a failing focused test, observe the expected failure, implement the smallest change, then rerun focused and full tests. -- Do not connect to or modify any production OpenWrt during this plan. Device deployment and remote verification require a separate explicit `ok`. -- Do not push, merge, tag, publish a release, or start a GitHub Actions package build during this local implementation phase. - -## Current-Tree Audit - -- Plan baseline is branch `codex/fix-release-install-instructions` at `10fa8b6`; do not switch, reset, rebase, or merge this worktree before preserving any newer user changes found at execution time. -- Baseline at plan creation: all existing `tests/*_test.sh` and `tests/*_test.js` pass. -- WAN direct already belongs in this app as an explicit, disabled-by-default option. It reads the current Tailscale listen port, supports multiple firewall source zones, creates idempotent `firewall.ts_wan_direct_*` rules, and removes them when disabled. -- `子网互通` maps to `disable_snat_subnet_routes=1`. The current init script passes `--snat-subnet-routes=false`, and `tailscale_helper` sets the app-owned `firewall.tszone.masq=0`. Turning it off restores `--snat-subnet-routes=true` and `masq=1`. -- The dual SNAT behavior is implemented but lacks a behavioral regression test. Task 1 adds that protection before OpenClash work begins. -- No OpenClash helper, hook lifecycle, dedicated UCI package, rpcd status method, or LuCI control exists yet. - -## File Map - -**Create:** - -- `root/usr/sbin/tailscale_openclash_bypass`: sole owner of the managed hook block and four nftables rules. -- `root/etc/config/tailscale_openclash`: app-owned toggle, isolated from the `tailscale` package reload trigger. -- `root/etc/init.d/tailscale-openclash-bypass`: one-shot reconciler with a reload trigger only for `tailscale_openclash`. -- `tests/tailscale_openclash_bypass_test.sh`: hook, nftables, locking, status, and cleanup behavior. -- `tests/tailscale_openclash_lifecycle_test.sh`: UCI/procd and package lifecycle isolation. -- `tests/setting_openclash_bypass_test.js`: LuCI separate-UCI binding and nonblocking status behavior. - -**Modify:** - -- `tests/tailscale_helper_network_cleanup_test.sh`: prove fw4 `masq` follows the site-to-site setting. -- `tests/tailscale_init_adguard_lifecycle_test.sh`: prove Tailscale receives the matching `--snat-subnet-routes` flag. -- `Makefile`: package install/upgrade/removal hooks and no new OpenClash service dependency. -- `root/usr/libexec/rpcd/luci.tailscale`: read-only OpenClash bypass status method. -- `root/usr/share/rpcd/acl.d/luci-app-tailscale.json`: read access for status and UCI access for `tailscale_openclash`. -- `htdocs/luci-static/resources/view/tailscale/setting.js`: OpenClash tab, toggle, and asynchronous status. -- `po/templates/tailscale.pot`, `po/zh_Hans/tailscale.po`, `po/zh_Hant/tailscale.po`: UI strings. -- `tests/package_release_test.sh`: package inventory and strict flow-isolation assertions. -- `README.md`: supported backend, ownership, status, and rollback documentation. - ---- - -### Task 1: Lock Down Existing WAN and Site-to-Site Contracts - -**Files:** -- Modify: `tests/tailscale_helper_network_cleanup_test.sh:243-425` -- Modify: `tests/tailscale_init_adguard_lifecycle_test.sh:21-356` -- Test: `tests/tailscale_helper_network_cleanup_test.sh` -- Test: `tests/tailscale_init_adguard_lifecycle_test.sh` - -**Interfaces:** -- Consumes: `DISABLE_SNAT_SUBNET_ROUTES`, `configure_tailscale_access_rules()`, and `tailscale_helper()` from the existing implementation. -- Produces: regression coverage proving `disable_snat_subnet_routes` controls both Tailscale `NoSNAT` and app-owned fw4 zone masquerade. - -- [ ] **Step 1: Extend the firewall test harness and write the failing no-SNAT assertions** - -In `run_helper()`, insert this assignment after the existing `access` assignment: - -```sh -disable_snat_subnet_routes="${6:-0}" -``` - -Replace the existing forced value: - -```sh -DISABLE_SNAT_SUBNET_ROUTES=0 -``` - -with: - -```sh -DISABLE_SNAT_SUBNET_ROUTES="$disable_snat_subnet_routes" -``` - -Then add this sequence after the existing access-rule assertions: - -```sh - -: >"$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" - -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" -``` - -- [ ] **Step 2: Run the focused firewall test and confirm the harness fails before accepting the new argument** - -Run: - -```sh -sh tests/tailscale_helper_network_cleanup_test.sh -``` - -Expected: FAIL because the original harness forces `DISABLE_SNAT_SUBNET_ROUTES=0`, so `firewall.tszone.masq=0` is absent. - -- [ ] **Step 3: Implement only the sixth-argument harness change and rerun the test** - -Run: - -```sh -sh tests/tailscale_helper_network_cleanup_test.sh -``` - -Expected: `tailscale_helper network cleanup tests passed`. - -- [ ] **Step 4: Add a behavioral init-script capture for both Tailscale SNAT values** - -Add a fake procd command recorder to `tests/tailscale_init_adguard_lifecycle_test.sh`: - -```sh -capture_tailscale_snat_args() { - disable_snat="$1" - PROCD_LOG="$TMP_DIR/procd-snat-$disable_snat.log" - : >"$PROCD_LOG" - export PROCD_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 - ) - cat "$PROCD_LOG" -} - -snat_disabled_args="$(capture_tailscale_snat_args 1)" -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" - -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" -``` - -- [ ] **Step 5: Run both focused tests** - -Run: - -```sh -sh tests/tailscale_helper_network_cleanup_test.sh -sh tests/tailscale_init_adguard_lifecycle_test.sh -``` - -Expected: both scripts print their existing `tests passed` messages. - -- [ ] **Step 6: Commit only the regression tests** - -```sh -git add tests/tailscale_helper_network_cleanup_test.sh tests/tailscale_init_adguard_lifecycle_test.sh -git commit -m "test: lock down Tailscale subnet SNAT behavior" -``` - -### Task 2: Implement Ownership-Safe OpenClash Hook Reconciliation - -**Files:** -- Create: `tests/tailscale_openclash_bypass_test.sh` -- Create: `root/usr/sbin/tailscale_openclash_bypass` - -**Interfaces:** -- Consumes: `/etc/openclash/custom/openclash_custom_firewall_rules.sh`, `flock`, and the app-owned `tailscale_openclash.settings.enabled` value. -- Produces: `tailscale_openclash_bypass reconcile-hook` and `tailscale_openclash_bypass cleanup`, with a uniquely delimited managed block and ownership-scoped rollback. - -- [ ] **Step 1: Create hook fixtures and failing behavior tests** - -Create `tests/tailscale_openclash_bypass_test.sh` with a temporary OpenClash tree and these assertions: - -```sh -#!/bin/sh -set -eu - -ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" -SCRIPT="$ROOT_DIR/root/usr/sbin/tailscale_openclash_bypass" -TMP_DIR="${TMPDIR:-/tmp}/tailscale-openclash-test.$$" -trap 'rm -rf "$TMP_DIR"' EXIT - -fail() { printf 'FAIL: %s\n' "$*" >&2; exit 1; } -assert_count() { - expected="$1"; needle="$2"; file="$3" - actual="$(grep -cF -- "$needle" "$file" || true)" - [ "$actual" = "$expected" ] || fail "$file expected $expected occurrences of $needle, got $actual" -} - -mkdir -p "$TMP_DIR/openclash/custom" "$TMP_DIR/bin" -touch "$TMP_DIR/openclash-init" -chmod +x "$TMP_DIR/openclash-init" -HOOK="$TMP_DIR/openclash/custom/openclash_custom_firewall_rules.sh" -printf '#!/bin/sh\nprintf user-before\nprintf user-after\n' >"$HOOK" -chmod 750 "$HOOK" -original_owner="$(stat -c '%u:%g' "$HOOK")" -original_hash="$(sha256sum "$HOOK" | awk '{print $1}')" - -run_helper() { - OPENCLASH_INIT="$TMP_DIR/openclash-init" \ - OPENCLASH_HOOK_FILE="$HOOK" \ - LOCK_FILE="$TMP_DIR/lock" \ - UCI_BIN="$TMP_DIR/bin/uci" \ - NFT_BIN="$TMP_DIR/bin/nft" \ - JQ_BIN=jq \ - "$SCRIPT" "$@" -} - -run_helper reconcile-hook -assert_count 1 '# BEGIN luci-app-tailscale 托管:Tailscale 绕过 OpenClash' "$HOOK" -assert_count 1 '# END luci-app-tailscale 托管:Tailscale 绕过 OpenClash' "$HOOK" -grep -F 'printf user-before' "$HOOK" >/dev/null || fail 'hook insertion removed user content before the block' -grep -F 'printf user-after' "$HOOK" >/dev/null || fail 'hook insertion removed user content after the block' -[ "$(stat -c '%a' "$HOOK")" = 750 ] || fail 'hook mode changed' -[ "$(stat -c '%u:%g' "$HOOK")" = "$original_owner" ] || fail 'hook ownership changed' - -first_hash="$(sha256sum "$HOOK" | awk '{print $1}')" -run_helper reconcile-hook -[ "$(sha256sum "$HOOK" | awk '{print $1}')" = "$first_hash" ] || fail 'reconcile-hook is not idempotent' - -cp "$HOOK" "$TMP_DIR/hook-before-malformed" -printf '%s\n' '# BEGIN luci-app-tailscale 托管:Tailscale 绕过 OpenClash' >>"$HOOK" -malformed_hash="$(sha256sum "$HOOK" | awk '{print $1}')" -if run_helper reconcile-hook >/dev/null 2>&1; then - fail 'duplicate managed markers must fail' -fi -[ "$(sha256sum "$HOOK" | awk '{print $1}')" = "$malformed_hash" ] || fail 'malformed hook changed after rejection' - -cp "$TMP_DIR/hook-before-malformed" "$HOOK" -run_helper cleanup -grep -F 'luci-app-tailscale 托管' "$HOOK" >/dev/null && fail 'cleanup left the managed hook block' -grep -F 'printf user-before' "$HOOK" >/dev/null || fail 'cleanup removed user content' -[ "$(sha256sum "$HOOK" | awk '{print $1}')" = "$original_hash" ] || \ - fail 'hook cleanup did not restore every original byte' -``` - -Also test these states in the same script: - -```sh -rm -f "$TMP_DIR/openclash-init" "$HOOK" -run_helper reconcile-hook -[ ! -e "$HOOK" ] || fail 'OpenClash-absent reconciliation created a custom script' - -touch "$TMP_DIR/openclash-init" -chmod +x "$TMP_DIR/openclash-init" -run_helper reconcile-hook -[ -x "$HOOK" ] || fail 'missing OpenClash custom script was not created executable' -sh -n "$HOOK" || fail 'created hook is not valid shell' -``` - -- [ ] **Step 2: Run the new test and confirm the helper is missing** - -Run: - -```sh -sh tests/tailscale_openclash_bypass_test.sh -``` - -Expected: FAIL because `root/usr/sbin/tailscale_openclash_bypass` does not exist. - -- [ ] **Step 3: Implement the hook-only helper commands** - -Create `root/usr/sbin/tailscale_openclash_bypass` with these constants and command contract: - -```sh -#!/bin/sh -set -u - -OPENCLASH_INIT="${OPENCLASH_INIT:-/etc/init.d/openclash}" -OPENCLASH_HOOK_FILE="${OPENCLASH_HOOK_FILE:-/etc/openclash/custom/openclash_custom_firewall_rules.sh}" -LOCK_FILE="${LOCK_FILE:-/var/lock/tailscale-openclash-bypass.lock}" -UCI_BIN="${UCI_BIN:-uci}" -NFT_BIN="${NFT_BIN:-nft}" -JQ_BIN="${JQ_BIN:-jq}" -LOGGER_CMD="${LOGGER_CMD:-logger}" - -BEGIN_MARKER='# BEGIN luci-app-tailscale 托管:Tailscale 绕过 OpenClash' -END_MARKER='# END luci-app-tailscale 托管:Tailscale 绕过 OpenClash' - -managed_block() { - cat <<'EOF' -# 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 -EOF -} -``` - -Implement these exact functions in the same file: - -```sh -openclash_installed() { test -x "$OPENCLASH_INIT"; } -log_error() { "$LOGGER_CMD" -p daemon.err -t tailscale_openclash_bypass "$*"; } - -marker_state() { - file="$1" - begin_count="$(grep -cF -- "$BEGIN_MARKER" "$file" 2>/dev/null || true)" - end_count="$(grep -cF -- "$END_MARKER" "$file" 2>/dev/null || true)" - [ "$begin_count" = 0 ] && [ "$end_count" = 0 ] && { printf 'absent\n'; return 0; } - [ "$begin_count" = 1 ] && [ "$end_count" = 1 ] || return 1 - begin_line="$(grep -nF -- "$BEGIN_MARKER" "$file" | cut -d: -f1)" - end_line="$(grep -nF -- "$END_MARKER" "$file" | cut -d: -f1)" - [ "$begin_line" -lt "$end_line" ] || return 1 - printf 'present\n' -} -``` - -`reconcile_hook()` must preserve bytes outside the managed block. Locate exact -whole-line markers with BusyBox-compatible tools; do not use `grep -b`. The -parser must distinguish marker-like user text, reject duplicate/malformed marker -pairs, and replace a noncanonical managed body. Use byte-preserving prefix/suffix -copies and insert the managed block immediately after the shebang line. Treat any -separator newline introduced for the block as part of the managed byte range so -`cleanup_hook()` restores the original file hash even when the original last line -has no newline. Build a temp file in the hook's directory, run `sh -n`, restore -numeric owner/group before mode, and atomically `mv` it over the hook. When the -file is absent, create a minimal `#!/bin/sh` script with mode `0755`. Any malformed -marker state must return nonzero before a write, and every failure or signal -before `mv` must delete only the tracked temp file. - -`cleanup_hook()` must use the same parser and atomic replacement path, but omit the managed block. It must return zero when OpenClash, the hook, or the managed block is absent. - -Acquire one exclusive lock before dispatching mutating commands: - -```sh -exec 9>"$LOCK_FILE" || exit 1 -flock -x 9 || exit 1 -case "${1:-}" in - reconcile-hook) openclash_installed || exit 0; reconcile_hook ;; - cleanup) cleanup_hook ;; - *) printf 'usage: %s {reconcile-hook|cleanup}\n' "$0" >&2; exit 2 ;; -esac -``` - -- [ ] **Step 4: Run hook tests and shell syntax checks** - -Run: - -```sh -sh -n root/usr/sbin/tailscale_openclash_bypass -sh tests/tailscale_openclash_bypass_test.sh -``` - -Expected: syntax passes and all hook assertions pass; nft assertions are added in Task 3. - -- [ ] **Step 5: Commit the hook reconciler** - -```sh -git add root/usr/sbin/tailscale_openclash_bypass tests/tailscale_openclash_bypass_test.sh -git commit -m "feat: add ownership-safe OpenClash hook" -``` - -### Task 3: Reconcile Four nftables Rules Atomically and Report Status - -**Files:** -- Modify: `tests/tailscale_openclash_bypass_test.sh` -- Modify: `root/usr/sbin/tailscale_openclash_bypass` - -**Interfaces:** -- Consumes: `nft -j -a list chain inet fw4 ` and `nft -f `. -- Produces: `apply`, complete `cleanup`, and JSON `status` with `active`, `waiting`, `disabled`, `absent`, `unsupported`, or `error` state. - -- [ ] **Step 1: Add a stateful fake nft command and failing rule tests** - -The fake must expose all four target chains, record the single `nft -f` batch, and return owned handles from JSON. Add assertions for these exact managed rules: - -```text -insert rule inet fw4 openclash_mangle_output meta mark & 0x00ff0000 == 0x00080000 counter comment "luci-app-tailscale: Tailscale 标记流量绕过 OpenClash(mangle output)" return -insert rule inet fw4 openclash_output meta mark & 0x00ff0000 == 0x00080000 counter comment "luci-app-tailscale: Tailscale 标记流量绕过 OpenClash(output)" return -insert rule inet fw4 openclash_mangle iifname "tailscale0" counter comment "luci-app-tailscale: tailscale0 入站流量绕过 OpenClash(mangle)" return -insert rule inet fw4 openclash iifname "tailscale0" counter comment "luci-app-tailscale: tailscale0 入站流量绕过 OpenClash(filter)" return -``` - -Add test cases that verify: - -```sh -run_helper apply -assert_count 1 'insert rule inet fw4 openclash_mangle_output ' "$NFT_BATCH_LOG" -assert_count 1 'insert rule inet fw4 openclash_output ' "$NFT_BATCH_LOG" -assert_count 1 'insert rule inet fw4 openclash_mangle iifname "tailscale0"' "$NFT_BATCH_LOG" -assert_count 1 'insert rule inet fw4 openclash iifname "tailscale0"' "$NFT_BATCH_LOG" - -run_helper apply -[ "$(grep -c 'insert rule inet fw4' "$NFT_STATE")" = 4 ] || fail 'repeated apply duplicated owned rules' - -MISSING_CHAIN=openclash_output run_helper apply -[ ! -s "$NFT_BATCH_LOG" ] || fail 'missing target chain produced a partial nft transaction' -printf '%s' "$(run_helper status)" | jq -e '.state == "waiting"' >/dev/null || fail 'missing chain did not report waiting' - -run_helper cleanup -grep -F 'luci-app-tailscale:' "$NFT_STATE" >/dev/null && fail 'cleanup left owned nft rules' -grep -F 'user-owned-rule' "$NFT_STATE" >/dev/null || fail 'cleanup removed a user-owned rule' -``` - -- [ ] **Step 2: Run the focused test and confirm `apply` and `status` fail** - -Run: - -```sh -sh tests/tailscale_openclash_bypass_test.sh -``` - -Expected: FAIL at the first `apply` assertion because Task 2 intentionally left nft commands unimplemented. - -- [ ] **Step 3: Implement complete-chain validation and one nft transaction** - -Add an exact rule registry: - -```sh -rule_specs() { - cat <<'EOF' -openclash_mangle_output|luci-app-tailscale: Tailscale 标记流量绕过 OpenClash(mangle output)|meta mark & 0x00ff0000 == 0x00080000 -openclash_output|luci-app-tailscale: Tailscale 标记流量绕过 OpenClash(output)|meta mark & 0x00ff0000 == 0x00080000 -openclash_mangle|luci-app-tailscale: tailscale0 入站流量绕过 OpenClash(mangle)|iifname "tailscale0" -openclash|luci-app-tailscale: tailscale0 入站流量绕过 OpenClash(filter)|iifname "tailscale0" -EOF -} -``` - -For each spec, first run `"$NFT_BIN" -j -a list chain inet fw4 "$chain"`. Do not create a batch until every call succeeds. Extract only handles whose JSON rule comment exactly equals the registered comment: - -```sh -printf '%s' "$chain_json" | "$JQ_BIN" -r --arg comment "$comment" ' - .nftables[] | .rule? | select(.comment == $comment) | .handle -' -``` - -Write all owned-handle deletions and four `insert rule` statements to one temp batch, then execute one `"$NFT_BIN" -f "$batch"`. A failed batch returns nonzero and is logged; nftables transaction semantics prevent partial application. - -`cleanup_rules()` uses the same exact-comment handle lookup and one delete-only batch. Missing chains are successful no-ops during cleanup, and user-owned equivalent rules are never selected. - -- [ ] **Step 4: Implement machine-readable status without mutating state** - -Use `jq -nc` to return one object with these fields: - -```json -{ - "state": "active", - "enabled": true, - "openclash_present": true, - "firewall4_supported": true, - "hook": "managed", - "rules_present": 4, - "message": "OpenClash bypass is active." -} -``` - -Status precedence must be: - -1. OpenClash absent -> `absent`. -2. firewall4 table or nft JSON unsupported -> `unsupported`. -3. configured disabled -> `disabled`. -4. malformed/duplicate markers -> `error`. -5. one or more target chains absent -> `waiting`. -6. hook and all four exact owned comments present -> `active`. -7. every other incomplete state -> `error`. - -Read only `tailscale_openclash.settings.enabled`; never query the `firewall` UCI package. Default enabled is `1` when the option is absent and OpenClash is installed. - -- [ ] **Step 5: Finish cleanup and rerun focused tests** - -`cleanup_all()` must call `cleanup_hook` and `cleanup_rules` under the same lock, preserve the first nonzero result, and never call firewall4 or OpenClash service commands. - -Replace the Task 2 dispatcher with the final command set: - -```sh -case "${1:-}" in - reconcile-hook) - openclash_installed || exit 0 - reconcile_hook - ;; - apply) - openclash_installed || exit 0 - feature_enabled || { cleanup_rules; exit $?; } - apply_rules - ;; - cleanup) - cleanup_all - ;; - status) - print_status - ;; - *) - printf 'usage: %s {reconcile-hook|apply|cleanup|status}\n' "$0" >&2 - exit 2 - ;; -esac -``` - -Run: - -```sh -sh -n root/usr/sbin/tailscale_openclash_bypass -sh tests/tailscale_openclash_bypass_test.sh -``` - -Expected: `tailscale OpenClash bypass tests passed`. - -- [ ] **Step 6: Commit atomic nft reconciliation** - -```sh -git add root/usr/sbin/tailscale_openclash_bypass tests/tailscale_openclash_bypass_test.sh -git commit -m "feat: reconcile OpenClash bypass rules" -``` - -### Task 4: Add an Independent UCI and Package Lifecycle - -**Files:** -- Create: `root/etc/config/tailscale_openclash` -- Create: `root/etc/init.d/tailscale-openclash-bypass` -- Create: `tests/tailscale_openclash_lifecycle_test.sh` -- Modify: `Makefile:7-21` -- Modify: `tests/package_release_test.sh:126-199,500-547` - -**Interfaces:** -- Consumes: helper commands `sync`, `apply`, and `cleanup` from Tasks 2-3. -- Produces: a package-owned UCI toggle whose reload trigger cannot invoke `/etc/init.d/tailscale` or the WAN-direct helper. - -- [ ] **Step 1: Write failing lifecycle and static-isolation tests** - -Create `tests/tailscale_openclash_lifecycle_test.sh` that sources the new init script with a fake helper and verifies: - -```sh -enabled_log="$(run_sync 1)" -[ "$enabled_log" = "sync" ] || fail "enabled OpenClash bypass must reconcile under one helper lock" - -disabled_log="$(run_sync 0)" -[ "$disabled_log" = "cleanup" ] || fail "disabled OpenClash bypass must remove only owned state" - -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/(firewall|openclash)|fw4 (reload|restart)' "$INIT_SCRIPT" >/dev/null && \ - fail 'OpenClash lifecycle must not manage firewall4 or OpenClash services' -``` - -Extend `tests/package_release_test.sh` with these hard boundaries: - -```sh -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_not_contains "tailscale_openclash_bypass" root/usr/sbin/tailscale_helper -assert_not_contains "openclash_custom_firewall_rules.sh" root/usr/sbin/tailscale_helper -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 -``` - -- [ ] **Step 2: Run both tests and confirm lifecycle files are missing** - -Run: - -```sh -sh tests/tailscale_openclash_lifecycle_test.sh -sh tests/package_release_test.sh -``` - -Expected: both fail on missing package files. - -- [ ] **Step 3: Add the dedicated UCI package and one-shot init script** - -Create `root/etc/config/tailscale_openclash`: - -```uci -config openclash 'settings' - option enabled '1' -``` - -Create `root/etc/init.d/tailscale-openclash-bypass`: - -```sh -#!/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() { - config_load tailscale_openclash - config_foreach sync_instance openclash -} - -reload_service() { - start_service -} - -stop_service() { - "$PROG" cleanup -} -``` - -This script must contain no reference to `/etc/init.d/tailscale`, `/etc/init.d/firewall`, `fw4 reload`, or OpenClash service actions. - -- [ ] **Step 4: Preserve standard installation and add removal cleanup** - -Do not define `Package/luci-app-tailscale/postinst`; the LuCI default post-install -script must retain cache cleanup and `rpcd` reload, while OpenWrt -`default_postinst` handles packaged init scripts. In `Makefile`, retain the -existing Tailscale stop behavior and add OpenClash cleanup before it: - -```make -define Package/luci-app-tailscale/prerm -#!/bin/sh -if [ -z "$${IPKG_INSTROOT}" ] && [ -x /usr/sbin/tailscale_openclash_bypass ]; then - /usr/sbin/tailscale_openclash_bypass cleanup >/dev/null 2>&1 || true -fi -if [ -z "$${IPKG_INSTROOT}" ] && [ -x /etc/init.d/tailscale ]; then - /etc/init.d/tailscale stop >/dev/null 2>&1 || exit 1 -fi -exit 0 -endef -``` - -The OpenClash cleanup is best-effort and runs while the helper still exists. It does not suppress the existing failure handling for Tailscale's own managed firewall cleanup. - -- [ ] **Step 5: Run focused lifecycle, package, and syntax tests** - -Run: - -```sh -sh -n root/etc/init.d/tailscale-openclash-bypass -sh tests/tailscale_openclash_lifecycle_test.sh -sh tests/package_release_test.sh -``` - -Expected: all pass. - -- [ ] **Step 6: Commit the isolated package lifecycle** - -```sh -git add Makefile root/etc/config/tailscale_openclash root/etc/init.d/tailscale-openclash-bypass tests/tailscale_openclash_lifecycle_test.sh tests/package_release_test.sh -git commit -m "feat: isolate OpenClash bypass lifecycle" -``` - -### Task 5: Add Read-Only Status RPC and LuCI Control - -**Files:** -- Create: `tests/setting_openclash_bypass_test.js` -- Modify: `root/usr/libexec/rpcd/luci.tailscale:6-180` -- Modify: `root/usr/share/rpcd/acl.d/luci-app-tailscale.json:4-24` -- Modify: `htdocs/luci-static/resources/view/tailscale/setting.js:16-62,349-362,470-766,849-852` - -**Interfaces:** -- Consumes: helper `status` JSON and UCI package `tailscale_openclash`. -- Produces: read-only rpcd method `openclash_bypass_status` and a LuCI tab whose setting writes only `tailscale_openclash.settings.enabled`. - -- [ ] **Step 1: Write failing rpcd and LuCI binding tests** - -Extend `tests/tailscale_rpcd_test.sh` to require the new method and exact helper pass-through: - -```sh -printf '%s\n' '{}' | OPENCLASH_BYPASS_BIN="$TMP_DIR/openclash-helper" \ - "$RPCD_SCRIPT" call openclash_bypass_status | \ - jq -e '.state == "active" and .rules_present == 4' >/dev/null || \ - fail 'rpcd must return the helper status object unchanged' -``` - -Create `tests/setting_openclash_bypass_test.js` with static and VM assertions: - -```js -const fs = require('fs'); -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'); - -const loadBody = source.match(/load\(\)\s*\{([\s\S]*?)\n\t\},\n\n\trender/)[1]; -assert(!loadBody.includes('callOpenclashBypassStatus('), 'OpenClash status must not block initial page rendering'); -assert(source.includes('refreshOpenclashBypassStatus'), 'status must refresh after render'); - -console.log('setting OpenClash bypass tests passed'); -``` - -- [ ] **Step 2: Run tests and confirm the RPC and UI are absent** - -Run: - -```sh -sh tests/tailscale_rpcd_test.sh -node tests/setting_openclash_bypass_test.js -``` - -Expected: both fail on missing `openclash_bypass_status` support. - -- [ ] **Step 3: Implement the read-only rpcd method** - -Add: - -```sh -OPENCLASH_BYPASS_BIN="${OPENCLASH_BYPASS_BIN:-/usr/sbin/tailscale_openclash_bypass}" - -run_openclash_bypass_status() { - "$OPENCLASH_BYPASS_BIN" status -} -``` - -Add `"openclash_bypass_status":{}` to `print_methods()` and dispatch it without parsing caller-controlled command arguments. In the ACL, grant read access to `openclash_bypass_status` and UCI read/write access to `tailscale_openclash`; do not expose helper execution through the generic LuCI file-exec ACL. - -- [ ] **Step 4: Add an asynchronous OpenClash tab to the existing settings page** - -Declare the RPC and status renderer: - -```js -const callOpenclashBypassStatus = rpc.declare({ - object: 'luci.tailscale', - method: 'openclash_bypass_status', - expect: { '': {} }, - reject: true -}); - -function renderOpenclashBypassStatus(status) { - const labels = { - active: _('Enabled and active'), - waiting: _('Enabled; waiting for OpenClash nftables chains'), - disabled: _('Disabled'), - absent: _('OpenClash is not installed'), - 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.'); - } -} -``` - -Load `tailscale_openclash` alongside the existing UCI loads, but do not call the status RPC from `load()`. Add a dedicated tab: - -```js -s.tab('openclash', _('OpenClash')); - -o = s.taboption('openclash', form.Flag, 'openclash_bypass_enabled', _('Enable OpenClash Bypass'), - _('Bypass OpenClash for Tailscale marked host traffic and traffic entering from tailscale0. This feature does not reload firewall4 or manage the OpenClash service.')); -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 ...')); -}; -``` - -After `m.render()`, schedule `refreshOpenclashBypassStatus` next to the existing AdGuard refresh: - -```js -window.setTimeout(refreshAdguardPreflightStatus, 0); -window.setTimeout(refreshOpenclashBypassStatus, 0); -``` - -- [ ] **Step 5: Run RPC, LuCI, and syntax tests** - -Run: - -```sh -sh tests/tailscale_rpcd_test.sh -node tests/setting_openclash_bypass_test.js -node --check htdocs/luci-static/resources/view/tailscale/setting.js -sh -n root/usr/libexec/rpcd/luci.tailscale -jq -e . root/usr/share/rpcd/acl.d/luci-app-tailscale.json >/dev/null -``` - -Expected: all commands pass. - -- [ ] **Step 6: Commit the UI and status API** - -```sh -git add htdocs/luci-static/resources/view/tailscale/setting.js root/usr/libexec/rpcd/luci.tailscale root/usr/share/rpcd/acl.d/luci-app-tailscale.json tests/setting_openclash_bypass_test.js tests/tailscale_rpcd_test.sh -git commit -m "feat: add OpenClash bypass controls" -``` - -### Task 6: Complete Translations, Documentation, and Full Local Verification - -**Files:** -- Modify: `po/templates/tailscale.pot` -- Modify: `po/zh_Hans/tailscale.po` -- Modify: `po/zh_Hant/tailscale.po` -- Modify: `README.md:64-89` -- Modify: `tests/package_release_test.sh` - -**Interfaces:** -- Consumes: state names and UI strings from Task 5. -- Produces: complete Simplified/Traditional Chinese UI, operational documentation, and a locally verified source tree ready for review. - -- [ ] **Step 1: Add failing translation inventory assertions** - -Require every new msgid in the template and both translations. At minimum assert these Simplified Chinese values: - -```text -OpenClash -> OpenClash -Enable OpenClash Bypass -> 启用 OpenClash 绕过 -Status -> 状态 -Enabled and active -> 已启用并生效 -Enabled; waiting for OpenClash nftables chains -> 已启用,等待 OpenClash 创建 nftables 链 -Disabled -> 已禁用 -OpenClash is not installed -> 未安装 OpenClash -Unsupported: firewall4/nftables is required -> 不支持:需要 firewall4/nftables -Configuration error -> 配置错误 -Unable to read OpenClash bypass status. -> 无法读取 OpenClash 绕过状态。 -``` - -Run: - -```sh -sh tests/package_release_test.sh -``` - -Expected: FAIL on the first missing translation assertion. - -- [ ] **Step 2: Update the POT and both PO files** - -Add all Task 5 msgids. Use natural Traditional Chinese equivalents in `po/zh_Hant/tailscale.po`, including `啟用 OpenClash 繞過`, `已啟用並生效`, and `未安裝 OpenClash`. Preserve existing translation entries and run format validation. - -- [ ] **Step 3: Document ownership, lifecycle, and rollback separately from WAN direct** - -Add a `## OpenClash Bypass` section to `README.md` containing these facts: - -````markdown -## 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. - -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 -``` -```` - -Do not add production hostnames, public IPs, credentials, or device-specific OpenClash content to the package README. - -- [ ] **Step 4: Run the complete existing and new behavior suite** - -Run: - -```sh -set -eu -for test in tests/*_test.sh; do sh "$test"; done -for test in tests/*_test.js; do node "$test"; done -``` - -Expected: every script exits zero and prints its pass message. - -- [ ] **Step 5: Run all local syntax, JSON, translation, and diff checks** - -Run: - -```sh -set -eu -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 -``` - -Expected: no output except test pass messages, and exit status zero. - -- [ ] **Step 6: Verify strict isolation from source text** - -Run: - -```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 -``` - -Expected: all three negated searches exit zero and print nothing. - -- [ ] **Step 7: Commit documentation and translations** - -```sh -git add README.md po/templates/tailscale.pot po/zh_Hans/tailscale.po po/zh_Hant/tailscale.po tests/package_release_test.sh -git commit -m "docs: document OpenClash bypass management" -``` - -### Task 7: Correct Final-Review Findings Against OpenWrt 24.10 - -**Files:** -- Modify: `docs/superpowers/specs/2026-07-16-tailscale-openclash-bypass-design.md` -- Modify: `docs/superpowers/plans/2026-07-17-tailscale-openclash-bypass.md` -- Modify: `root/usr/sbin/tailscale_openclash_bypass` -- Modify: `root/etc/init.d/tailscale-openclash-bypass` -- Modify: `root/usr/sbin/tailscale_helper` -- Modify: `Makefile` -- Modify: `tests/tailscale_openclash_bypass_test.sh` -- Modify: `tests/tailscale_openclash_lifecycle_test.sh` -- Modify: `tests/tailscale_helper_network_cleanup_test.sh` -- Modify: `tests/package_release_test.sh` -- Modify: `README.md` - -**Interfaces:** -- Preserves: WAN-direct ownership of firewall UCI/firewall4 reloads only. -- Preserves: OpenClash ownership of one official custom-hook block and four - comment-owned runtime nftables rules only. -- Produces: one-lock `sync`, BusyBox-compatible hook handling, standard LuCI - installation lifecycle, and empty-access no-SNAT correctness. - -- [x] **Step 1: Add focused failing regression tests** - -Add tests proving: - -1. no production path invokes `grep -b`, and the hook works with a fake grep that - rejects `-b`; -2. marker-like user text is preserved, exact duplicate/malformed markers fail, - and a stale managed body is replaced canonically; -3. `sync` performs one locked decision after re-reading UCI, including concurrent - enable/disable coverage; -4. missing OpenClash or unsupported fw4 produces no hook/rule side effect, while - present fw4 with missing OpenClash chains produces `waiting` with a canonical - hook; -5. cleanup succeeds when the fw4 table or target chains are already absent, and - a disappearing chain is reclassified from refreshed table state; -6. false aliases `0`, `false`, `off`, `no`, and `disabled` disable the feature; -7. site-to-site no-SNAT with an empty access list keeps `tszone.masq=0` and creates - no forwarding sections; disabling no-SNAT with empty access removes the zone; -8. standard LuCI post-install behavior is not replaced by a custom package - `postinst`; -9. replacement restores owner before mode and tracked temporary files are removed - after termination; -10. fake nftables rejects nonexistent handles and the locking test uses real - `flock` behavior. - -Run each focused test before implementation and retain the expected failure -output in the task report. - -- [x] **Step 2: Implement the smallest complete corrections** - -Implement `sync` under one exclusive lock. It re-reads UCI itself and dispatches -the full enabled/disabled state. Keep `apply` for the OpenClash-owned hook, but do -not split procd reconciliation into two helper invocations. Use exact whole-line, -canonical hook parsing without `grep -b`; clean only package-owned state; never -touch firewall UCI or manage OpenClash/firewall4 services. - -Remove the custom package post-install block. Keep best-effort package removal -cleanup. Decouple `tszone` existence from forwarding-token existence when -site-to-site no-SNAT is enabled. - -- [x] **Step 3: Strengthen lifecycle, race, and documentation coverage** - -Document all status states and ownership cleanup. Make the fake nftables harness -fail on stale/nonexistent handles and add a deterministic generation-race case. -Verify unsupported, waiting, disabled, active, and cleanup behavior through the -public command interface. - -- [x] **Step 4: Run focused and full verification** - -Run: - -```sh -set -eu -sh tests/tailscale_openclash_bypass_test.sh -sh tests/tailscale_openclash_lifecycle_test.sh -sh tests/tailscale_helper_network_cleanup_test.sh -sh tests/package_release_test.sh -for test in tests/*_test.sh; do sh "$test"; done -for test in tests/*_test.js; do node "$test"; done -``` - -Then run every syntax, JSON, translation, strict-isolation, and diff check from -Task 6. No router is modified in Task 7. - -- [x] **Step 5: Commit Task 7 as one review-fix commit** - -Stage only Task 7 paths and preserve the pre-existing modified SDD reports. -Record the final commit range and request a fresh whole-branch review against -merge base `7f100d587f473eb73d8736ef9731d644c75b6b2d`. - -## Local Completion Gate - -After Task 7, stop. Report: - -- the exact local commits created; -- the complete test and syntax results; -- `git status --short --branch` without changing or staging unrelated files; -- confirmation that no router, server, GitHub branch, PR, tag, release, or Actions workflow was modified. - -Production deployment remains a separate operation requiring a new explicit `ok`. That later verification must first read current device state, take rollback backups, and then independently verify: - -1. OpenClash hook content outside the managed block is unchanged. -2. Four owned rules are at the heads of the expected chains with no duplicates. -3. OpenClash restart recreates the rules through its custom hook without a firewall4 reload. -4. Disabling removes only the owned block and rules. -5. WAN direct still creates only its firewall UCI rules and cold-start direct connectivity works. -6. Site-to-site enabled shows both Tailscale `NoSNAT=true` and `firewall.tszone.masq='0'`, with routing table 52 intact. diff --git a/luci-app-tailscale/docs/superpowers/plans/2026-07-18-openclash-bypass-ui-copy.md b/luci-app-tailscale/docs/superpowers/plans/2026-07-18-openclash-bypass-ui-copy.md deleted file mode 100644 index 71421133a..000000000 --- a/luci-app-tailscale/docs/superpowers/plans/2026-07-18-openclash-bypass-ui-copy.md +++ /dev/null @@ -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. diff --git a/luci-app-tailscale/docs/superpowers/plans/2026-07-18-tailscale-current-device-list.md b/luci-app-tailscale/docs/superpowers/plans/2026-07-18-tailscale-current-device-list.md deleted file mode 100644 index b33d7099e..000000000 --- a/luci-app-tailscale/docs/superpowers/plans/2026-07-18-tailscale-current-device-list.md +++ /dev/null @@ -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" -``` diff --git a/luci-app-tailscale/docs/superpowers/plans/2026-07-23-tailscale-mwan3-policy-routing.md b/luci-app-tailscale/docs/superpowers/plans/2026-07-23-tailscale-mwan3-policy-routing.md deleted file mode 100644 index b672adb50..000000000 --- a/luci-app-tailscale/docs/superpowers/plans/2026-07-23-tailscale-mwan3-policy-routing.md +++ /dev/null @@ -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 单独重启和路由状态变更。 diff --git a/luci-app-tailscale/docs/superpowers/specs/2026-07-06-adguard-dns-switch-design.md b/luci-app-tailscale/docs/superpowers/specs/2026-07-06-adguard-dns-switch-design.md deleted file mode 100644 index 297e72aa5..000000000 --- a/luci-app-tailscale/docs/superpowers/specs/2026-07-06-adguard-dns-switch-design.md +++ /dev/null @@ -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. diff --git a/luci-app-tailscale/docs/superpowers/specs/2026-07-13-tailscale-peer-observability-design.md b/luci-app-tailscale/docs/superpowers/specs/2026-07-13-tailscale-peer-observability-design.md deleted file mode 100644 index 921a2afae..000000000 --- a/luci-app-tailscale/docs/superpowers/specs/2026-07-13-tailscale-peer-observability-design.md +++ /dev/null @@ -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 -``` - -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. diff --git a/luci-app-tailscale/docs/superpowers/specs/2026-07-16-tailscale-openclash-bypass-design.md b/luci-app-tailscale/docs/superpowers/specs/2026-07-16-tailscale-openclash-bypass-design.md deleted file mode 100644 index c0a861624..000000000 --- a/luci-app-tailscale/docs/superpowers/specs/2026-07-16-tailscale-openclash-bypass-design.md +++ /dev/null @@ -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. diff --git a/luci-app-tailscale/docs/superpowers/specs/2026-07-18-openclash-bypass-ui-copy-design.md b/luci-app-tailscale/docs/superpowers/specs/2026-07-18-openclash-bypass-ui-copy-design.md deleted file mode 100644 index 55884fe98..000000000 --- a/luci-app-tailscale/docs/superpowers/specs/2026-07-18-openclash-bypass-ui-copy-design.md +++ /dev/null @@ -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 缓存并执行同等级别验证。 diff --git a/luci-app-tailscale/docs/superpowers/specs/2026-07-18-tailscale-current-device-list-design.md b/luci-app-tailscale/docs/superpowers/specs/2026-07-18-tailscale-current-device-list-design.md deleted file mode 100644 index d49511af2..000000000 --- a/luci-app-tailscale/docs/superpowers/specs/2026-07-18-tailscale-current-device-list-design.md +++ /dev/null @@ -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` 时保持兼容。 -- 验证当前设备行显示“当前设备”且没有可点击的探测按钮。 -- 运行现有对端页面测试和完整项目测试,确认筛选、分页、探测及打包检查无回归。 diff --git a/luci-app-tailscale/docs/superpowers/specs/2026-07-23-tailscale-mwan3-policy-routing-design.md b/luci-app-tailscale/docs/superpowers/specs/2026-07-23-tailscale-mwan3-policy-routing-design.md deleted file mode 100644 index 559629061..000000000 --- a/luci-app-tailscale/docs/superpowers/specs/2026-07-23-tailscale-mwan3-policy-routing-design.md +++ /dev/null @@ -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 处理。 diff --git a/luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/interface.js b/luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/interface.js index 68098c241..4d1878ac4 100644 --- a/luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/interface.js +++ b/luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/interface.js @@ -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 []; } diff --git a/luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/log.js b/luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/log.js index 2f8601c9f..bdb995c33 100644 --- a/luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/log.js +++ b/luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/log.js @@ -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() { diff --git a/luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js b/luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js deleted file mode 100644 index f20655343..000000000 --- a/luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js +++ /dev/null @@ -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 -}); diff --git a/luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js b/luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js index 513004803..2d5462776 100644 --- a/luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js +++ b/luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js @@ -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(); } }); diff --git a/luci-app-tailscale/po/templates/tailscale.pot b/luci-app-tailscale/po/templates/tailscale.pot index e567bdb18..b546effb7 100644 --- a/luci-app-tailscale/po/templates/tailscale.pot +++ b/luci-app-tailscale/po/templates/tailscale.pot @@ -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 "" diff --git a/luci-app-tailscale/po/zh_Hans/tailscale.po b/luci-app-tailscale/po/zh_Hans/tailscale.po index af7ba6661..6ec185139 100644 --- a/luci-app-tailscale/po/zh_Hans/tailscale.po +++ b/luci-app-tailscale/po/zh_Hans/tailscale.po @@ -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. 10.0.0.0/24." -msgstr "将本地物理网段发布到 Tailscale,例如:10.0.0.0/24。" +msgstr "广播子网路由至 Tailscale,例如:10.0.0.0/24。" #: 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 策略路由状态。" diff --git a/luci-app-tailscale/po/zh_Hant/tailscale.po b/luci-app-tailscale/po/zh_Hant/tailscale.po index 5376dfc66..fa5a70e7b 100644 --- a/luci-app-tailscale/po/zh_Hant/tailscale.po +++ b/luci-app-tailscale/po/zh_Hant/tailscale.po @@ -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. 10.0.0.0/24." -msgstr "將本地物理網段發布到 Tailscale,例如:10.0.0.0/24。" +msgstr "廣播子網路由至 Tailscale,例如:10.0.0.0/24。" #: 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 策略路由狀態。" diff --git a/luci-app-tailscale/root/etc/config/tailscale b/luci-app-tailscale/root/etc/config/tailscale index acb576fbc..19a5b87ac 100644 --- a/luci-app-tailscale/root/etc/config/tailscale +++ b/luci-app-tailscale/root/etc/config/tailscale @@ -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 '' diff --git a/luci-app-tailscale/root/etc/config/tailscale_openclash b/luci-app-tailscale/root/etc/config/tailscale_openclash deleted file mode 100644 index b97df2055..000000000 --- a/luci-app-tailscale/root/etc/config/tailscale_openclash +++ /dev/null @@ -1,2 +0,0 @@ -config openclash 'settings' - option enabled '1' diff --git a/luci-app-tailscale/root/etc/config/tailscale_policy_routing b/luci-app-tailscale/root/etc/config/tailscale_policy_routing deleted file mode 100644 index a8bf54b1e..000000000 --- a/luci-app-tailscale/root/etc/config/tailscale_policy_routing +++ /dev/null @@ -1,3 +0,0 @@ -config settings 'settings' - # Prefer Tailscale table 52 before mwan3 fwmark policy rules. - option enabled '0' diff --git a/luci-app-tailscale/root/etc/hotplug.d/iface/40-tailscale b/luci-app-tailscale/root/etc/hotplug.d/iface/40-tailscale new file mode 100755 index 000000000..971ed550d --- /dev/null +++ b/luci-app-tailscale/root/etc/hotplug.d/iface/40-tailscale @@ -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 & diff --git a/luci-app-tailscale/root/etc/hotplug.d/iface/98-tailscale-policy-routing b/luci-app-tailscale/root/etc/hotplug.d/iface/98-tailscale-policy-routing deleted file mode 100755 index c327c4397..000000000 --- a/luci-app-tailscale/root/etc/hotplug.d/iface/98-tailscale-policy-routing +++ /dev/null @@ -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' diff --git a/luci-app-tailscale/root/etc/init.d/tailscale b/luci-app-tailscale/root/etc/init.d/tailscale index 6ce86a030..cc63258ca 100755 --- a/luci-app-tailscale/root/etc/init.d/tailscale +++ b/luci-app-tailscale/root/etc/init.d/tailscale @@ -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 } diff --git a/luci-app-tailscale/root/etc/init.d/tailscale-openclash-bypass b/luci-app-tailscale/root/etc/init.d/tailscale-openclash-bypass deleted file mode 100755 index 5c79019ec..000000000 --- a/luci-app-tailscale/root/etc/init.d/tailscale-openclash-bypass +++ /dev/null @@ -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 -} diff --git a/luci-app-tailscale/root/etc/init.d/tailscale-policy-routing b/luci-app-tailscale/root/etc/init.d/tailscale-policy-routing deleted file mode 100755 index 642ce613a..000000000 --- a/luci-app-tailscale/root/etc/init.d/tailscale-policy-routing +++ /dev/null @@ -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 -} diff --git a/luci-app-tailscale/root/etc/uci-defaults/40_luci-tailscale b/luci-app-tailscale/root/etc/uci-defaults/40_luci-tailscale index f7fe9b1d9..90cd2d6b3 100755 --- a/luci-app-tailscale/root/etc/uci-defaults/40_luci-tailscale +++ b/luci-app-tailscale/root/etc/uci-defaults/40_luci-tailscale @@ -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 diff --git a/luci-app-tailscale/root/lib/upgrade/keep.d/luci-app-tailscale b/luci-app-tailscale/root/lib/upgrade/keep.d/luci-app-tailscale deleted file mode 100644 index 9d4b0ce88..000000000 --- a/luci-app-tailscale/root/lib/upgrade/keep.d/luci-app-tailscale +++ /dev/null @@ -1 +0,0 @@ -/etc/tailscale/luci-secrets.json diff --git a/luci-app-tailscale/root/usr/libexec/rpcd/luci.tailscale b/luci-app-tailscale/root/usr/libexec/rpcd/luci.tailscale deleted file mode 100755 index 333be9187..000000000 --- a/luci-app-tailscale/root/usr/libexec/rpcd/luci.tailscale +++ /dev/null @@ -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 diff --git a/luci-app-tailscale/root/usr/sbin/tailscale_adguard_dns_switch b/luci-app-tailscale/root/usr/sbin/tailscale_adguard_dns_switch deleted file mode 100755 index a942f3971..000000000 --- a/luci-app-tailscale/root/usr/sbin/tailscale_adguard_dns_switch +++ /dev/null @@ -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 diff --git a/luci-app-tailscale/root/usr/sbin/tailscale_helper b/luci-app-tailscale/root/usr/sbin/tailscale_helper index 08c9240f0..bd1d54277 100755 --- a/luci-app-tailscale/root/usr/sbin/tailscale_helper +++ b/luci-app-tailscale/root/usr/sbin/tailscale_helper @@ -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:-}" - 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 diff --git a/luci-app-tailscale/root/usr/sbin/tailscale_keepalive b/luci-app-tailscale/root/usr/sbin/tailscale_keepalive deleted file mode 100755 index 3cefdd5a2..000000000 --- a/luci-app-tailscale/root/usr/sbin/tailscale_keepalive +++ /dev/null @@ -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 ... | --run ..." >&2 - exit 2 - ;; -esac diff --git a/luci-app-tailscale/root/usr/sbin/tailscale_openclash_bypass b/luci-app-tailscale/root/usr/sbin/tailscale_openclash_bypass deleted file mode 100755 index bf8de62a3..000000000 --- a/luci-app-tailscale/root/usr/sbin/tailscale_openclash_bypass +++ /dev/null @@ -1,1077 +0,0 @@ -#!/bin/sh -set -u - -OPENCLASH_INIT="${OPENCLASH_INIT:-/etc/init.d/openclash}" -OPENCLASH_HOOK_FILE="${OPENCLASH_HOOK_FILE:-/etc/openclash/custom/openclash_custom_firewall_rules.sh}" -LOCK_FILE="${LOCK_FILE:-/var/lock/tailscale-openclash-bypass.lock}" -UCI_BIN="${UCI_BIN:-uci}" -NFT_BIN="${NFT_BIN:-nft}" -JQ_BIN="${JQ_BIN:-jq}" -LOGGER_CMD="${LOGGER_CMD:-logger}" - -BEGIN_MARKER='# BEGIN luci-app-tailscale 托管:Tailscale 绕过 OpenClash' -END_MARKER='# END luci-app-tailscale 托管:Tailscale 绕过 OpenClash' -HOOK_TEMP_FILE= -NFT_TEMP_FILE= -NFT_CACHE_DIR= -NFT_GUARD_NAME= - -cleanup_tracked_temps() { - [ -z "$HOOK_TEMP_FILE" ] || rm -f "$HOOK_TEMP_FILE" - [ -z "$NFT_TEMP_FILE" ] || rm -f "$NFT_TEMP_FILE" - [ -z "$NFT_CACHE_DIR" ] || rm -rf "$NFT_CACHE_DIR" - [ -z "$NFT_GUARD_NAME" ] || \ - "$NFT_BIN" delete counter inet fw4 "$NFT_GUARD_NAME" >/dev/null 2>&1 || true -} - -trap cleanup_tracked_temps EXIT -trap 'exit 1' HUP INT TERM - -managed_block() { - cat <<'EOF' -# 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 -EOF -} - -openclash_installed() { test -x "$OPENCLASH_INIT"; } -log_error() { "$LOGGER_CMD" -p daemon.err -t tailscale_openclash_bypass "$*"; } - -marker_offsets() { - file="$1" - [ ! -e "$file" ] && { printf 'absent\n'; return 0; } - [ -f "$file" ] || return 1 - LC_ALL=C awk -v begin_marker="$BEGIN_MARKER" -v end_marker="$END_MARKER" ' - BEGIN { - offset = 0 - begin_count = 0 - end_count = 0 - } - { - if ($0 == begin_marker) { - begin_count++ - begin_offset = offset - } - if ($0 == end_marker) { - end_count++ - end_offset = offset - } - offset += length($0) + 1 - } - END { - if (begin_count == 0 && end_count == 0) { - print "absent" - exit 0 - } - if (begin_count != 1 || end_count != 1 || begin_offset >= end_offset) - exit 1 - printf "present|%d|%d\n", begin_offset, end_offset - } - ' "$file" -} - -marker_state() { - offsets="$(marker_offsets "$1")" || return 1 - printf '%s\n' "${offsets%%|*}" -} - -canonical_hook_state() { - offsets="$(marker_offsets "$1")" || return 1 - state="${offsets%%|*}" - [ "$state" = present ] || { - printf '%s\n' "$state" - return 0 - } - rest="${offsets#*|}" - begin_offset="${rest%%|*}" - end_offset="${rest#*|}" - end_length="$(printf '%s' "$END_MARKER" | wc -c | tr -d '[:space:]')" - block_length=$((end_offset + end_length - begin_offset)) - actual_block="$(dd if="$1" bs=1 skip="$begin_offset" count="$block_length" 2>/dev/null)" || return 1 - expected_block="$(managed_block)" || return 1 - if [ "$actual_block" = "$expected_block" ]; then - printf 'managed\n' - else - printf 'stale\n' - fi -} - -file_mode() { - LC_ALL=C ls -ldn "$1" 2>/dev/null | awk ' - NR == 1 { - permissions = substr($1, 2, 9) - if (permissions !~ /^[r-][w-][xSs-][r-][w-][xSs-][r-][w-][xTt-]$/) - exit 1 - - user = (substr(permissions, 1, 1) == "r" ? 4 : 0) + \ - (substr(permissions, 2, 1) == "w" ? 2 : 0) + \ - (substr(permissions, 3, 1) ~ /[xs]/ ? 1 : 0) - group = (substr(permissions, 4, 1) == "r" ? 4 : 0) + \ - (substr(permissions, 5, 1) == "w" ? 2 : 0) + \ - (substr(permissions, 6, 1) ~ /[xs]/ ? 1 : 0) - other = (substr(permissions, 7, 1) == "r" ? 4 : 0) + \ - (substr(permissions, 8, 1) == "w" ? 2 : 0) + \ - (substr(permissions, 9, 1) ~ /[xt]/ ? 1 : 0) - special = (substr(permissions, 3, 1) ~ /[sS]/ ? 4 : 0) + \ - (substr(permissions, 6, 1) ~ /[sS]/ ? 2 : 0) + \ - (substr(permissions, 9, 1) ~ /[tT]/ ? 1 : 0) - - found = 1 - printf "%d%d%d%d\n", special, user, group, other - exit - } - END { if (!found) exit 1 } - ' -} - -file_owner() { - LC_ALL=C ls -ldn "$1" 2>/dev/null | awk ' - NR == 1 { - if ($3 !~ /^[0-9]+$/ || $4 !~ /^[0-9]+$/) - exit 1 - found = 1 - printf "%s:%s\n", $3, $4 - exit - } - END { if (!found) exit 1 } - ' -} - -byte_is_newline() { - newline=' -' - probe="$({ - dd if="$1" bs=1 skip="$2" count=1 2>/dev/null - printf '.%s' "$?" - })" - probe_status="${probe##*.}" - byte="${probe%.*}" - [ "$probe_status" = 0 ] || return 2 - [ "$byte" = "$newline" ] -} - -shebang_size() { - first_line="$(head -n 1 "$1")" - case "$first_line" in - '#!'*) ;; - *) return 1 ;; - esac - - size="$(head -n 1 "$1" | wc -c | tr -d '[:space:]')" - [ "$size" -gt 0 ] || return 1 - byte_is_newline "$1" $((size - 1)) || return 1 - printf '%s\n' "$size" -} - -new_hook_temp_file() { - directory="$(dirname "$OPENCLASH_HOOK_FILE")" - HOOK_TEMP_FILE="$(mktemp "$directory/.tailscale-openclash-hook.XXXXXX")" || return 1 -} - -discard_hook_temp() { - [ -z "$HOOK_TEMP_FILE" ] || rm -f "$HOOK_TEMP_FILE" - HOOK_TEMP_FILE= -} - -finish_replacement() { - mode="$1" - owner="$2" - - if ! sh -n "$HOOK_TEMP_FILE"; then - discard_hook_temp - return 1 - fi - if [ -n "$owner" ] && ! chown "$owner" "$HOOK_TEMP_FILE"; then - discard_hook_temp - return 1 - fi - if ! chmod "$mode" "$HOOK_TEMP_FILE"; then - discard_hook_temp - return 1 - fi - if ! mv -f "$HOOK_TEMP_FILE" "$OPENCLASH_HOOK_FILE"; then - discard_hook_temp - return 1 - fi - HOOK_TEMP_FILE= -} - -reconcile_hook() { - if [ ! -e "$OPENCLASH_HOOK_FILE" ]; then - mkdir -p "$(dirname "$OPENCLASH_HOOK_FILE")" || return 1 - new_hook_temp_file || return 1 - if ! printf '#!/bin/sh\n' >"$HOOK_TEMP_FILE"; then - discard_hook_temp - return 1 - fi - if ! managed_block >>"$HOOK_TEMP_FILE"; then - discard_hook_temp - return 1 - fi - finish_replacement 0755 '' - return $? - fi - - offsets="$(marker_offsets "$OPENCLASH_HOOK_FILE")" || return 1 - state="${offsets%%|*}" - case "$state" in - absent) - prefix_offset="$(shebang_size "$OPENCLASH_HOOK_FILE")" || return 1 - suffix_offset="$prefix_offset" - ;; - present) - rest="${offsets#*|}" - prefix_offset="${rest%%|*}" - end_offset="${rest#*|}" - end_length="$(printf '%s' "$END_MARKER" | wc -c | tr -d '[:space:]')" - suffix_offset=$((end_offset + end_length)) - byte_is_newline "$OPENCLASH_HOOK_FILE" "$suffix_offset" - byte_result=$? - case "$byte_result" in - 0) suffix_offset=$((suffix_offset + 1)) ;; - 1) ;; - *) return 1 ;; - esac - ;; - *) return 1 ;; - esac - - mode="$(file_mode "$OPENCLASH_HOOK_FILE")" || return 1 - owner="$(file_owner "$OPENCLASH_HOOK_FILE")" || return 1 - new_hook_temp_file || return 1 - - if ! dd if="$OPENCLASH_HOOK_FILE" bs=1 count="$prefix_offset" 2>/dev/null >"$HOOK_TEMP_FILE"; then - discard_hook_temp - return 1 - fi - if ! managed_block >>"$HOOK_TEMP_FILE"; then - discard_hook_temp - return 1 - fi - if ! dd if="$OPENCLASH_HOOK_FILE" bs=1 skip="$suffix_offset" 2>/dev/null >>"$HOOK_TEMP_FILE"; then - discard_hook_temp - return 1 - fi - - finish_replacement "$mode" "$owner" -} - -cleanup_hook() { - openclash_installed || return 0 - [ -e "$OPENCLASH_HOOK_FILE" ] || return 0 - - offsets="$(marker_offsets "$OPENCLASH_HOOK_FILE")" || return 1 - state="${offsets%%|*}" - [ "$state" = absent ] && return 0 - - rest="${offsets#*|}" - begin_offset="${rest%%|*}" - end_offset="${rest#*|}" - end_length="$(printf '%s' "$END_MARKER" | wc -c | tr -d '[:space:]')" - suffix_offset=$((end_offset + end_length)) - byte_is_newline "$OPENCLASH_HOOK_FILE" "$suffix_offset" - byte_result=$? - case "$byte_result" in - 0) suffix_offset=$((suffix_offset + 1)) ;; - 1) ;; - *) return 1 ;; - esac - - mode="$(file_mode "$OPENCLASH_HOOK_FILE")" || return 1 - owner="$(file_owner "$OPENCLASH_HOOK_FILE")" || return 1 - new_hook_temp_file || return 1 - - if ! dd if="$OPENCLASH_HOOK_FILE" bs=1 count="$begin_offset" 2>/dev/null >"$HOOK_TEMP_FILE"; then - discard_hook_temp - return 1 - fi - if ! dd if="$OPENCLASH_HOOK_FILE" bs=1 skip="$suffix_offset" 2>/dev/null >>"$HOOK_TEMP_FILE"; then - discard_hook_temp - return 1 - fi - - finish_replacement "$mode" "$owner" -} - -rule_specs() { - cat <<'EOF' -openclash_mangle_output|luci-app-tailscale: Tailscale 标记流量绕过 OpenClash(mangle output)|meta mark & 0x00ff0000 == 0x00080000 -openclash_output|luci-app-tailscale: Tailscale 标记流量绕过 OpenClash(output)|meta mark & 0x00ff0000 == 0x00080000 -openclash_mangle|luci-app-tailscale: tailscale0 入站流量绕过 OpenClash(mangle)|iifname "tailscale0" -openclash|luci-app-tailscale: tailscale0 入站流量绕过 OpenClash(filter)|iifname "tailscale0" -EOF -} - -feature_state() { - if ! section_output="$("$UCI_BIN" -q show tailscale_openclash.settings 2>/dev/null)"; then - printf 'invalid\n' - return 0 - fi - section_count="$(printf '%s\n' "$section_output" | awk ' - $0 == "tailscale_openclash.settings=openclash" { count++ } - END { print count + 0 } - ')" || section_count=0 - [ "$section_count" = 1 ] || { - printf 'invalid\n' - return 0 - } - option_count="$(printf '%s\n' "$section_output" | awk ' - index($0, "tailscale_openclash.settings.enabled=") == 1 { count++ } - END { print count + 0 } - ')" || option_count=2 - case "$option_count" in - 0) normalized=1 ;; - 1) - if ! configured="$("$UCI_BIN" -q get tailscale_openclash.settings.enabled 2>/dev/null)"; then - printf 'invalid\n' - return 0 - fi - normalized="$(printf '%s' "$configured" | tr '[:upper:]' '[:lower:]')" || { - printf 'invalid\n' - return 0 - } - ;; - *) - printf 'invalid\n' - return 0 - ;; - esac - - case "$normalized" in - 1|true|on|yes|enabled) printf 'enabled\n' ;; - 0|false|off|no|disabled) printf 'disabled\n' ;; - *) printf 'invalid\n' ;; - esac -} - -new_nft_temp_file() { - mktemp "${TMPDIR:-/tmp}/tailscale-openclash-nft.XXXXXX" -} - -new_nft_cache_dir() { - mktemp -d "${TMPDIR:-/tmp}/tailscale-openclash-nft-cache.XXXXXX" -} - -discard_nft_temp() { - [ -z "$NFT_TEMP_FILE" ] || rm -f "$NFT_TEMP_FILE" - NFT_TEMP_FILE= -} - -discard_nft_cache() { - [ -z "$NFT_CACHE_DIR" ] || rm -rf "$NFT_CACHE_DIR" - NFT_CACHE_DIR= -} - -create_generation_guard() { - cache_dir="$1" - guard_suffix="${cache_dir##*.}" - case "$guard_suffix" in - ''|*[!A-Za-z0-9_]*) return 1 ;; - esac - NFT_GUARD_NAME="luci_app_tailscale_guard_$$_$guard_suffix" - if ! "$NFT_BIN" add counter inet fw4 "$NFT_GUARD_NAME"; then - NFT_GUARD_NAME= - return 1 - fi -} - -discard_nft_guard() { - guard_name="$NFT_GUARD_NAME" - [ -n "$guard_name" ] || return 0 - if "$NFT_BIN" delete counter inet fw4 "$guard_name" >/dev/null 2>&1; then - [ "$NFT_GUARD_NAME" != "$guard_name" ] || NFT_GUARD_NAME= - return 0 - fi - log_error "Failed to remove tracked nftables generation guard $guard_name; EXIT cleanup will retry." || true - return 1 -} - -append_guard_deletion() { - [ -n "$NFT_GUARD_NAME" ] || return 1 - printf 'delete counter inet fw4 %s\n' "$NFT_GUARD_NAME" -} - -nft_json_supported() { - printf '%s\n' "$1" | "$JQ_BIN" -e '.nftables | type == "array"' >/dev/null 2>&1 -} - -nft_chain_json_supported() { - printf '%s\n' "$1" | "$JQ_BIN" -e --arg chain "$2" ' - (.nftables | type == "array") and - ([.nftables[] | .chain? | - select(.family == "inet" and .table == "fw4" and .name == $chain)] | length == 1) and - all(.nftables[]; - (type == "object") and - if has("metainfo") then - (.metainfo | type == "object") - elif has("chain") then - (.chain | type == "object") and - (.chain.family == "inet") and - (.chain.table == "fw4") and - (.chain.name == $chain) - elif has("rule") then - (.rule | type == "object") and - (.rule.family == "inet") and - (.rule.table == "fw4") and - (.rule.chain == $chain) and - (.rule.handle | type == "number") - else - false - end - ) - ' >/dev/null 2>&1 -} - -nft_table_inventory_supported() { - printf '%s\n' "$1" | "$JQ_BIN" -e ' - (.nftables | type == "array") and - all(.nftables[]; - if has("metainfo") then - (.metainfo | type == "object") - elif has("table") then - (.table | type == "object") and - (.table.family | type == "string") and - (.table.name | type == "string") - else - false - end - ) - ' >/dev/null 2>&1 -} - -fw4_inventory_count() { - printf '%s\n' "$1" | "$JQ_BIN" -r ' - [.nftables[] | .table? | - select(.family == "inet" and .name == "fw4")] | length - ' -} - -table_json_has_fw4() { - printf '%s\n' "$1" | "$JQ_BIN" -e ' - [.nftables[] | .table? | - select(.family == "inet" and .name == "fw4")] | length == 1 - ' >/dev/null 2>&1 -} - -read_fw4_table() { - if ! inventory_json="$("$NFT_BIN" -j list tables 2>/dev/null)"; then - return 1 - fi - nft_table_inventory_supported "$inventory_json" || return 1 - inventory_count="$(fw4_inventory_count "$inventory_json")" || return 1 - case "$inventory_count" in - 0) return 2 ;; - 1) ;; - *) return 1 ;; - esac - if ! snapshot_json="$("$NFT_BIN" -j list table inet fw4 2>/dev/null)"; then - return 3 - fi - nft_json_supported "$snapshot_json" || return 3 - table_json_has_fw4 "$snapshot_json" || return 3 - printf '%s\n' "$snapshot_json" -} - -table_has_chain() { - table_json="$1" - chain="$2" - printf '%s\n' "$table_json" | "$JQ_BIN" -e --arg chain "$chain" ' - .nftables[] | .chain? | - select(.family == "inet" and .table == "fw4" and .name == $chain) - ' >/dev/null 2>&1 -} - -all_target_chains_present() { - table_json="$1" - specs="$(rule_specs)" - while IFS='|' read -r chain comment match; do - table_has_chain "$table_json" "$chain" || return 1 - done <"$handles_file" || return 1 - [ -n "$handles" ] || return 0 - - while IFS= read -r handle || [ -n "$handle" ]; do - case "$handle" in - ''|*[!0-9]*) return 1 ;; - esac - printf '%s\n' "$handle" >>"$handles_file" || return 1 - done </dev/null)"; then - refreshed_state="$(refreshed_chain_state "$chain")" || refreshed_state=unsupported - case "$refreshed_state" in - absent) - if ! discard_nft_guard; then - discard_nft_cache - return 1 - fi - discard_nft_cache - cleanup_rules - return $? - ;; - table-absent) - NFT_GUARD_NAME= - discard_nft_cache - return 0 - ;; - *) - log_error "OpenClash nftables chain $chain could not be read." || true - discard_nft_guard - discard_nft_cache - return 1 - ;; - esac - fi - if ! nft_chain_json_supported "$chain_json" "$chain"; then - log_error "OpenClash nftables JSON is unsupported for chain $chain." || true - discard_nft_guard - discard_nft_cache - return 1 - fi - if ! cache_owned_handles "$cache_dir" "$chain" "$comment" "$chain_json"; then - log_error "Failed to read owned OpenClash nftables rules from chain $chain." || true - discard_nft_guard - discard_nft_cache - return 1 - fi - done <"$NFT_TEMP_FILE"; then - discard_nft_guard - discard_nft_cache - discard_nft_temp - return 1 - fi - if ! append_cached_deletions "$cache_dir" >>"$NFT_TEMP_FILE"; then - discard_nft_guard - discard_nft_cache - discard_nft_temp - return 1 - fi - discard_nft_cache - if ! append_managed_rules >>"$NFT_TEMP_FILE"; then - discard_nft_guard - discard_nft_temp - return 1 - fi - if ! "$NFT_BIN" -f "$NFT_TEMP_FILE"; then - log_error 'OpenClash nftables reconciliation transaction failed.' || true - discard_nft_guard - discard_nft_temp - return 1 - fi - NFT_GUARD_NAME= - discard_nft_temp -} - -cleanup_rules() { - table_json="$(read_fw4_table)" - table_result=$? - case "$table_result" in - 0) ;; - 2) return 0 ;; - *) - log_error 'OpenClash nftables table state could not be read.' || true - return 1 - ;; - esac - cache_dir="$(new_nft_cache_dir)" || return 1 - NFT_CACHE_DIR="$cache_dir" - if ! create_generation_guard "$cache_dir"; then - discard_nft_cache - return 1 - fi - specs="$(rule_specs)" - - while IFS='|' read -r chain comment match; do - : >"$cache_dir/$chain" || { - discard_nft_guard - discard_nft_cache - return 1 - } - table_has_chain "$table_json" "$chain" || continue - if ! chain_json="$("$NFT_BIN" -j -a list chain inet fw4 "$chain" 2>/dev/null)"; then - refreshed_state="$(refreshed_chain_state "$chain")" || refreshed_state=unsupported - case "$refreshed_state" in - absent) continue ;; - table-absent) - discard_nft_guard - discard_nft_cache - return 0 - ;; - *) - log_error "OpenClash nftables chain $chain could not be read." || true - discard_nft_guard - discard_nft_cache - return 1 - ;; - esac - fi - if ! nft_chain_json_supported "$chain_json" "$chain"; then - log_error "OpenClash nftables JSON is unsupported for chain $chain." || true - discard_nft_guard - discard_nft_cache - return 1 - fi - if ! cache_owned_handles "$cache_dir" "$chain" "$comment" "$chain_json"; then - log_error "Failed to read owned OpenClash nftables rules from chain $chain." || true - discard_nft_guard - discard_nft_cache - return 1 - fi - done <"$NFT_TEMP_FILE"; then - discard_nft_guard - discard_nft_cache - discard_nft_temp - return 1 - fi - if ! append_cached_deletions "$cache_dir" >>"$NFT_TEMP_FILE"; then - discard_nft_guard - discard_nft_cache - discard_nft_temp - return 1 - fi - discard_nft_cache - if [ ! -s "$NFT_TEMP_FILE" ]; then - discard_nft_guard - discard_nft_temp - return 0 - fi - if ! "$NFT_BIN" -f "$NFT_TEMP_FILE"; then - log_error 'OpenClash nftables cleanup transaction failed.' || true - discard_nft_guard - discard_nft_temp - return 1 - fi - NFT_GUARD_NAME= - discard_nft_temp -} - -cleanup_all() { - cleanup_rules || return $? - cleanup_hook -} - -sync_all() { - openclash_installed || return 0 - current_feature_state="$(feature_state)" || return 1 - case "$current_feature_state" in - disabled) - cleanup_all - return $? - ;; - invalid) - log_error 'OpenClash bypass enabled setting is invalid.' || true - return 1 - ;; - enabled) ;; - *) return 1 ;; - esac - - sync_table_json="$(read_fw4_table)" - sync_table_result=$? - case "$sync_table_result" in - 0) ;; - 2) return 0 ;; - *) - log_error 'OpenClash nftables table state could not be read.' || true - return 1 - ;; - esac - reconcile_hook || return 1 - apply_rules -} - -print_status_json() { - state="$1" - enabled="$2" - openclash_present="$3" - firewall4_supported="$4" - hook="$5" - rules_present="$6" - message="$7" - "$JQ_BIN" -nc \ - --arg state "$state" \ - --argjson enabled "$enabled" \ - --argjson openclash_present "$openclash_present" \ - --argjson firewall4_supported "$firewall4_supported" \ - --arg hook "$hook" \ - --argjson rules_present "$rules_present" \ - --arg message "$message" \ - '{state: $state, enabled: ($enabled == 1), openclash_present: ($openclash_present == 1), firewall4_supported: ($firewall4_supported == 1), hook: $hook, rules_present: $rules_present, message: $message}' -} - -print_disabled_status() { - disabled_table_json="$1" - disabled_firewall4_supported="${2:-1}" - if ! disabled_hook_state="$(canonical_hook_state "$OPENCLASH_HOOK_FILE")"; then - disabled_hook=error - else - case "$disabled_hook_state" in - managed) disabled_hook=managed ;; - absent) disabled_hook=absent ;; - *) disabled_hook=error ;; - esac - fi - - disabled_rules_present="$(owned_rule_count "$disabled_table_json")" || { - print_status_json error 0 1 "$disabled_firewall4_supported" "$disabled_hook" 0 \ - 'OpenClash bypass rule ownership data could not be read.' - return - } - case "$disabled_rules_present" in - ''|*[!0-9]*) - print_status_json error 0 1 "$disabled_firewall4_supported" "$disabled_hook" 0 \ - 'OpenClash bypass rule ownership data is invalid.' - return - ;; - esac - - if [ "$disabled_hook" = absent ] && [ "$disabled_rules_present" = 0 ]; then - print_status_json disabled 0 1 "$disabled_firewall4_supported" absent 0 'OpenClash bypass is disabled.' - return - fi - print_status_json error 0 1 "$disabled_firewall4_supported" "$disabled_hook" "$disabled_rules_present" \ - 'OpenClash bypass is disabled but managed artifacts remain.' -} - -print_disabled_read_error_status() { - disabled_firewall4_supported="$1" - disabled_message="$2" - if ! disabled_hook_state="$(canonical_hook_state "$OPENCLASH_HOOK_FILE")"; then - disabled_hook=error - else - case "$disabled_hook_state" in - managed) disabled_hook=managed ;; - absent) disabled_hook=absent ;; - *) disabled_hook=error ;; - esac - fi - print_status_json error 0 1 "$disabled_firewall4_supported" "$disabled_hook" 0 "$disabled_message" -} - -print_status() { - if ! openclash_installed; then - print_status_json absent 0 0 0 absent 0 'OpenClash is not installed.' - return - fi - - current_feature_state="$(feature_state)" || current_feature_state=invalid - case "$current_feature_state" in - enabled) enabled=1 ;; - *) enabled=0 ;; - esac - if [ "$current_feature_state" = invalid ]; then - print_status_json error 0 1 0 error 0 'OpenClash bypass enabled setting is invalid.' - return - fi - - table_json="$(read_fw4_table)" - table_result=$? - if [ "$current_feature_state" = disabled ]; then - case "$table_result" in - 0) print_disabled_status "$table_json" 1 ;; - 2) print_disabled_status '{"nftables":[]}' 0 ;; - 1) print_disabled_read_error_status 0 'Unable to read firewall4/nftables state.' ;; - *) print_disabled_read_error_status 1 'Unable to read firewall4/nftables state.' ;; - esac - return - fi - - case "$table_result" in - 0) ;; - 2) - print_status_json unsupported "$enabled" 1 0 unknown 0 'firewall4/nftables support is unavailable.' - return - ;; - 1) - print_status_json error "$enabled" 1 0 unknown 0 'Unable to read firewall4/nftables state.' - return - ;; - *) - print_status_json error "$enabled" 1 1 unknown 0 'Unable to read firewall4/nftables state.' - return - ;; - esac - - if ! hook_state="$(canonical_hook_state "$OPENCLASH_HOOK_FILE")"; then - print_status_json error 1 1 1 error 0 'Managed hook markers are malformed.' - return - fi - case "$hook_state" in - managed) hook=managed ;; - absent) hook=absent ;; - stale) - print_status_json error 1 1 1 error 0 'Managed hook body is not canonical.' - return - ;; - *) print_status_json error 1 1 1 error 0 'Managed hook markers are malformed.'; return ;; - esac - - complete_rules=1 - rules_present="$(owned_rule_count "$table_json")" || { - print_status_json error 1 1 1 "$hook" 0 'OpenClash bypass rule ownership data could not be read.' - return - } - case "$rules_present" in - ''|*[!0-9]*) - print_status_json error 1 1 1 "$hook" 0 'OpenClash bypass rule ownership data is invalid.' - return - ;; - esac - if ! all_target_chains_present "$table_json"; then - print_status_json waiting 1 1 1 "$hook" "$rules_present" 'Waiting for OpenClash nftables chains.' - return - fi - - specs="$(rule_specs)" - while IFS='|' read -r chain comment match; do - if ! chain_json="$("$NFT_BIN" -j -a list chain inet fw4 "$chain" 2>/dev/null)"; then - refreshed_state="$(refreshed_chain_state "$chain")" || refreshed_state=unsupported - case "$refreshed_state" in - absent) - print_status_json waiting 1 1 1 "$hook" "$rules_present" 'Waiting for OpenClash nftables chains.' - return - ;; - table-absent) - print_status_json unsupported 1 1 0 "$hook" "$rules_present" 'firewall4/nftables support is unavailable.' - return - ;; - error|present) - print_status_json error 1 1 1 "$hook" "$rules_present" "OpenClash nftables chain $chain could not be read." - return - ;; - *) - print_status_json error 1 1 1 "$hook" "$rules_present" "OpenClash nftables chain $chain could not be read." - return - ;; - esac - fi - if ! nft_chain_json_supported "$chain_json" "$chain"; then - print_status_json error 1 1 1 "$hook" "$rules_present" "OpenClash nftables JSON is invalid for chain $chain." - return - fi - case "$chain" in - openclash_mangle_output|openclash_output) rule_kind=mark ;; - *) rule_kind=ingress ;; - esac - canonical_count="$(canonical_owned_rule_count "$chain_json" "$chain" "$comment" "$rule_kind")" || { - print_status_json error 1 1 1 "$hook" "$rules_present" 'OpenClash bypass rule structure could not be read.' - return - } - [ "$canonical_count" = 1 ] || complete_rules=0 - done <"$LOCK_FILE" || exit 1 - flock -x 9 || exit 1 - case "$1" in - sync) - sync_all - ;; - reconcile-hook) - openclash_installed || exit 0 - reconcile_hook - ;; - apply) - openclash_installed || exit 0 - current_feature_state="$(feature_state)" || current_feature_state=invalid - case "$current_feature_state" in - enabled) apply_rules ;; - disabled) cleanup_all ;; - *) log_error 'OpenClash bypass enabled setting is invalid.' || true; exit 1 ;; - esac - ;; - cleanup) - cleanup_all - ;; - esac - ;; - *) - printf 'usage: %s {sync|reconcile-hook|apply|cleanup|status}\n' "$0" >&2 - exit 2 - ;; -esac diff --git a/luci-app-tailscale/root/usr/sbin/tailscale_peer_probe b/luci-app-tailscale/root/usr/sbin/tailscale_peer_probe deleted file mode 100755 index b25239b62..000000000 --- a/luci-app-tailscale/root/usr/sbin/tailscale_peer_probe +++ /dev/null @@ -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 " "" - 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 diff --git a/luci-app-tailscale/root/usr/sbin/tailscale_policy_routing b/luci-app-tailscale/root/usr/sbin/tailscale_policy_routing deleted file mode 100755 index 27ac150a9..000000000 --- a/luci-app-tailscale/root/usr/sbin/tailscale_policy_routing +++ /dev/null @@ -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 diff --git a/luci-app-tailscale/root/usr/sbin/tailscale_secrets b/luci-app-tailscale/root/usr/sbin/tailscale_secrets deleted file mode 100755 index 3f8f81816..000000000 --- a/luci-app-tailscale/root/usr/sbin/tailscale_secrets +++ /dev/null @@ -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 diff --git a/luci-app-tailscale/root/usr/share/luci/menu.d/luci-app-tailscale.json b/luci-app-tailscale/root/usr/share/luci/menu.d/luci-app-tailscale.json index 12a0a4ae4..946749d71 100644 --- a/luci-app-tailscale/root/usr/share/luci/menu.d/luci-app-tailscale.json +++ b/luci-app-tailscale/root/usr/share/luci/menu.d/luci-app-tailscale.json @@ -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, diff --git a/luci-app-tailscale/root/usr/share/rpcd/acl.d/luci-app-tailscale.json b/luci-app-tailscale/root/usr/share/rpcd/acl.d/luci-app-tailscale.json index afbdb7eed..f5ef1cd1c 100644 --- a/luci-app-tailscale/root/usr/share/rpcd/acl.d/luci-app-tailscale.json +++ b/luci-app-tailscale/root/usr/share/rpcd/acl.d/luci-app-tailscale.json @@ -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" ] } } } diff --git a/luci-app-tailscale/tests/log_view_test.js b/luci-app-tailscale/tests/log_view_test.js deleted file mode 100644 index 54cfec234..000000000 --- a/luci-app-tailscale/tests/log_view_test.js +++ /dev/null @@ -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'); diff --git a/luci-app-tailscale/tests/package_release_test.sh b/luci-app-tailscale/tests/package_release_test.sh deleted file mode 100644 index 5672ecfdd..000000000 --- a/luci-app-tailscale/tests/package_release_test.sh +++ /dev/null @@ -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 -} diff --git a/luci-app-tailscale/tests/peers_pagination_test.js b/luci-app-tailscale/tests/peers_pagination_test.js deleted file mode 100644 index 79ba88785..000000000 --- a/luci-app-tailscale/tests/peers_pagination_test.js +++ /dev/null @@ -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); -}); diff --git a/luci-app-tailscale/tests/release_workflow_test.sh b/luci-app-tailscale/tests/release_workflow_test.sh deleted file mode 100644 index 183ac22ac..000000000 --- a/luci-app-tailscale/tests/release_workflow_test.sh +++ /dev/null @@ -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' diff --git a/luci-app-tailscale/tests/setting_openclash_bypass_test.js b/luci-app-tailscale/tests/setting_openclash_bypass_test.js deleted file mode 100644 index c524a30ea..000000000 --- a/luci-app-tailscale/tests/setting_openclash_bypass_test.js +++ /dev/null @@ -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); -}); diff --git a/luci-app-tailscale/tests/setting_policy_routing_test.js b/luci-app-tailscale/tests/setting_policy_routing_test.js deleted file mode 100755 index 495b72a8f..000000000 --- a/luci-app-tailscale/tests/setting_policy_routing_test.js +++ /dev/null @@ -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'); diff --git a/luci-app-tailscale/tests/setting_preflight_test.js b/luci-app-tailscale/tests/setting_preflight_test.js deleted file mode 100644 index eb8f48853..000000000 --- a/luci-app-tailscale/tests/setting_preflight_test.js +++ /dev/null @@ -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); -}); diff --git a/luci-app-tailscale/tests/tailscale_adguard_dns_switch_test.sh b/luci-app-tailscale/tests/tailscale_adguard_dns_switch_test.sh deleted file mode 100755 index c19e11eb7..000000000 --- a/luci-app-tailscale/tests/tailscale_adguard_dns_switch_test.sh +++ /dev/null @@ -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" diff --git a/luci-app-tailscale/tests/tailscale_helper_network_cleanup_test.sh b/luci-app-tailscale/tests/tailscale_helper_network_cleanup_test.sh deleted file mode 100755 index 6380bb368..000000000 --- a/luci-app-tailscale/tests/tailscale_helper_network_cleanup_test.sh +++ /dev/null @@ -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" diff --git a/luci-app-tailscale/tests/tailscale_init_adguard_lifecycle_test.sh b/luci-app-tailscale/tests/tailscale_init_adguard_lifecycle_test.sh deleted file mode 100644 index a0d7b0d1e..000000000 --- a/luci-app-tailscale/tests/tailscale_init_adguard_lifecycle_test.sh +++ /dev/null @@ -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:-}" -[ "$(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:-}" - -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:-}" - -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:-}" -[ ! -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" diff --git a/luci-app-tailscale/tests/tailscale_keepalive_test.sh b/luci-app-tailscale/tests/tailscale_keepalive_test.sh deleted file mode 100755 index c8709a89b..000000000 --- a/luci-app-tailscale/tests/tailscale_keepalive_test.sh +++ /dev/null @@ -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' diff --git a/luci-app-tailscale/tests/tailscale_legacy_adguard_migration_test.sh b/luci-app-tailscale/tests/tailscale_legacy_adguard_migration_test.sh deleted file mode 100755 index a0cb34ace..000000000 --- a/luci-app-tailscale/tests/tailscale_legacy_adguard_migration_test.sh +++ /dev/null @@ -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" diff --git a/luci-app-tailscale/tests/tailscale_openclash_bypass_test.sh b/luci-app-tailscale/tests/tailscale_openclash_bypass_test.sh deleted file mode 100644 index 89b4b5f22..000000000 --- a/luci-app-tailscale/tests/tailscale_openclash_bypass_test.sh +++ /dev/null @@ -1,1220 +0,0 @@ -#!/bin/sh -set -eu - -ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" -SCRIPT="$ROOT_DIR/root/usr/sbin/tailscale_openclash_bypass" -TMP_DIR="${TMPDIR:-/tmp}/tailscale-openclash-test.$$" -REAL_CHOWN="$(command -v chown)" -REAL_CHMOD="$(command -v chmod)" -REAL_DD="$(command -v dd)" -REAL_GREP="$(command -v grep)" -SYNC_ENABLE_PID= -SYNC_DISABLE_PID= - -cleanup() { - [ -z "$SYNC_ENABLE_PID" ] || kill "$SYNC_ENABLE_PID" >/dev/null 2>&1 || true - [ -z "$SYNC_DISABLE_PID" ] || kill "$SYNC_DISABLE_PID" >/dev/null 2>&1 || true - rm -rf "$TMP_DIR" -} -trap cleanup EXIT - -fail() { printf 'FAIL: %s\n' "$*" >&2; exit 1; } -file_mode() { - stat -c '%a' "$1" 2>/dev/null || stat -f '%Lp' "$1" -} -file_owner() { - stat -c '%u:%g' "$1" 2>/dev/null || stat -f '%u:%g' "$1" -} -file_permissions() { - LC_ALL=C ls -ld "$1" | awk '{ print substr($1, 1, 10) }' -} -assert_count() { - expected="$1"; needle="$2"; file="$3" - actual="$(grep -cF -- "$needle" "$file" || true)" - [ "$actual" = "$expected" ] || fail "$file expected $expected occurrences of $needle, got $actual" -} -assert_line_count() { - expected="$1"; line="$2"; file="$3" - actual="$(grep -Fxc -- "$line" "$file" || true)" - [ "$actual" = "$expected" ] || fail "$file expected $expected exact occurrences of $line, got $actual" -} -assert_chown_owner() { - owner="$1" - grep -F -- "$owner " "$CHOWN_LOG" >/dev/null || fail "chown did not receive numeric owner/group $owner" -} -managed_hook_present() { - grep -Fx '# BEGIN luci-app-tailscale 托管:Tailscale 绕过 OpenClash' "$1" >/dev/null 2>&1 -} -assert_owner_before_mode() { - owner_line="$(grep -n '^chown ' "$REPLACEMENT_LOG" | tail -n 1 | cut -d: -f1)" - mode_line="$(grep -n '^chmod ' "$REPLACEMENT_LOG" | tail -n 1 | cut -d: -f1)" - [ -n "$owner_line" ] || fail 'replacement did not restore hook ownership' - [ -n "$mode_line" ] || fail 'replacement did not restore hook mode' - [ "$owner_line" -lt "$mode_line" ] || fail 'replacement must restore owner before mode' -} - -if grep -E 'grep[[:space:]]+-[^[:space:]]*b' "$SCRIPT" >/dev/null; then - fail 'OpenClash helper must not use grep -b on BusyBox 1.36' -fi - -mkdir -p "$TMP_DIR/openclash/custom" "$TMP_DIR/bin" "$TMP_DIR/tmp" -cat >"$TMP_DIR/bin/flock" <<'PL' -#!/usr/bin/perl -use strict; -use warnings; -use Fcntl qw(LOCK_EX); - -@ARGV == 2 && $ARGV[0] eq '-x' && $ARGV[1] =~ /^\d+$/ or die "unsupported flock arguments\n"; -open my $lock_fh, "<&=$ARGV[1]" or die "cannot inherit lock fd: $!\n"; -flock($lock_fh, LOCK_EX) or die "cannot acquire lock: $!\n"; -PL -chmod +x "$TMP_DIR/bin/flock" -cat >"$TMP_DIR/bin/grep" <<'SH' -#!/bin/sh -for arg in "$@"; do - case "$arg" in - --byte-offset|-*b*) - printf 'fake grep rejects byte offsets: %s\n' "$arg" >&2 - exit 64 - ;; - esac -done -exec "${REAL_GREP:?}" "$@" -SH -chmod +x "$TMP_DIR/bin/grep" -cat >"$TMP_DIR/bin/stat" <<'SH' -#!/bin/sh -printf 'stat is unavailable on the target OpenWrt image\n' >&2 -exit 127 -SH -chmod +x "$TMP_DIR/bin/stat" -cat >"$TMP_DIR/bin/od" <<'SH' -#!/bin/sh -printf 'od is unavailable on the target OpenWrt image\n' >&2 -exit 127 -SH -chmod +x "$TMP_DIR/bin/od" -cat >"$TMP_DIR/bin/chown" <<'SH' -#!/bin/sh -printf '%s\n' "$*" >>"${CHOWN_LOG:?}" -printf 'chown %s\n' "$*" >>"${REPLACEMENT_LOG:?}" -exec "${REAL_CHOWN:?}" "$@" -SH -chmod +x "$TMP_DIR/bin/chown" -cat >"$TMP_DIR/bin/chmod" <<'SH' -#!/bin/sh -printf 'chmod %s\n' "$*" >>"${REPLACEMENT_LOG:?}" -exec "${REAL_CHMOD:?}" "$@" -SH -chmod +x "$TMP_DIR/bin/chmod" -cat >"$TMP_DIR/bin/dd" <<'SH' -#!/bin/sh -has_count=0 -has_skip=0 -for arg in "$@"; do - case "$arg" in - count=*) has_count=1 ;; - skip=*) has_skip=1 ;; - esac -done -if [ "${DD_FAIL_PREFIX:-0}" = 1 ] && [ "$has_count" = 1 ] && [ "$has_skip" = 0 ]; then - printf 'failed: %s\n' "$*" >>"${DD_LOG:?}" - exit 1 -fi -if [ "${DD_FAIL_PROBE:-0}" = 1 ] && [ "$has_count" = 1 ] && [ "$has_skip" = 1 ]; then - printf 'probe-failed: %s\n' "$*" >>"${DD_LOG:?}" - exit 1 -fi -if [ "${DD_SIGNAL_PARENT:-0}" = 1 ] && [ "$has_count" = 1 ] && [ "$has_skip" = 0 ]; then - printf 'signalled: %s\n' "$*" >>"${DD_LOG:?}" - kill -TERM "$PPID" - exit 1 -fi -printf 'passed: %s\n' "$*" >>"${DD_LOG:?}" -exec "${REAL_DD:?}" "$@" -SH -chmod +x "$TMP_DIR/bin/dd" -cat >"$TMP_DIR/bin/uci" <<'SH' -#!/bin/sh -set -eu - -printf '%s\n' "$*" >>"${UCI_LOG:?}" -case "$*" in - '-q show tailscale_openclash.settings') - case "${UCI_SECTION_MODE:-present}" in - present) - printf '%s\n' 'tailscale_openclash.settings=openclash' - [ -z "${FEATURE_ENABLED:-}" ] || \ - printf "tailscale_openclash.settings.enabled='%s'\n" "$FEATURE_ENABLED" - ;; - missing) exit 1 ;; - error) exit 74 ;; - malformed) printf '%s\n' 'malformed uci section output' ;; - *) exit 99 ;; - esac - ;; - '-q get tailscale_openclash.settings.enabled') - printf 'value:%s\n' "${FEATURE_ENABLED:-default}" >>"${UCI_LOG:?}" - [ "${UCI_SECTION_MODE:-present}" = present ] || exit 1 - [ "${UCI_OPTION_GET_FAIL:-0}" = 0 ] || exit 75 - if [ -n "${UCI_READY_FILE:-}" ]; then - : >"$UCI_READY_FILE" - while [ ! -e "${UCI_RELEASE_FILE:?}" ]; do sleep 0.05; done - fi - [ -n "${FEATURE_ENABLED:-}" ] || exit 1 - printf '%s\n' "$FEATURE_ENABLED" - ;; - *) - printf 'unsupported fake uci command: %s\n' "$*" >&2 - exit 99 - ;; -esac -SH -chmod +x "$TMP_DIR/bin/uci" -cat >"$TMP_DIR/bin/logger" <<'SH' -#!/bin/sh -printf '%s\n' "$*" >>"${LOGGER_LOG:?}" -SH -chmod +x "$TMP_DIR/bin/logger" -cat >"$TMP_DIR/bin/nft" <<'SH' -#!/bin/sh -set -eu - -state="${NFT_STATE:?}" -guard_state="${NFT_GUARD_STATE:?}" -batch_log="${NFT_BATCH_LOG:?}" -call_log="${NFT_CALL_LOG:?}" -table_state="${NFT_TABLE_STATE:-PRESENT}" - -printf '%s\n' "$*" >>"$call_log" - -print_rule_json() { - handle="$1" - rule="$2" - rest="${rule#insert rule inet fw4 }" - rule_chain="${rest%% *}" - comment="$(printf '%s\n' "$rule" | sed -n 's/.* return comment "\(.*\)"$/\1/p')" - [ -n "$comment" ] || return 0 - json_chain="$rule_chain" - case "$rule" in - *' meta mark & 0x00ff0000 == 0x00080000 counter return comment '*) - match_json='{"match":{"op":"==","left":{"&":[{"meta":{"key":"mark"}},16711680]},"right":524288}}' - ;; - *' iifname "tailscale0" counter return comment '*) - match_json='{"match":{"op":"==","left":{"meta":{"key":"iifname"}},"right":"tailscale0"}}' - ;; - *) - match_json='{"match":{"op":"==","left":{"meta":{"key":"iifname"}},"right":"unrelated0"}}' - ;; - esac - counter_json="{\"counter\":{\"packets\":${NFT_COUNTER_PACKETS:-0},\"bytes\":${NFT_COUNTER_BYTES:-0}}}" - verdict_json='{"return":null}' - handle_json=",\"handle\":$handle" - case "$comment" in - luci-app-tailscale:*) - case "${NFT_RULE_JSON_MODE:-canonical}" in - altered-match) - match_json='{"match":{"op":"==","left":{"meta":{"key":"mark"}},"right":0}}' - ;; - overbroad) match_json= ;; - incomplete) verdict_json= ;; - altered-verdict) verdict_json='{"accept":null}' ;; - wrong-chain) json_chain='wrong_chain' ;; - esac - if [ "${NFT_CHAIN_JSON_TARGET:-}" = "$rule_chain" ]; then - case "${NFT_CHAIN_JSON_MODE:-canonical}" in - wrong-rule-chain) json_chain='wrong_chain' ;; - missing-owned-handle) handle_json= ;; - esac - fi - ;; - esac - if [ -n "$match_json" ] && [ -n "$verdict_json" ]; then - expr_json="[$match_json,$counter_json,$verdict_json]" - elif [ -n "$match_json" ]; then - expr_json="[$match_json,$counter_json]" - else - expr_json="[$counter_json,$verdict_json]" - fi - printf ',{"rule":{"family":"inet","table":"fw4","chain":"%s"%s,"expr":%s,"comment":"%s"}}' \ - "$json_chain" "$handle_json" "$expr_json" "$comment" -} - -print_chain_json() { - chain="$1" - chain_json_mode=canonical - [ "${NFT_CHAIN_JSON_TARGET:-}" != "$chain" ] || chain_json_mode="${NFT_CHAIN_JSON_MODE:-canonical}" - [ "$chain_json_mode" != empty ] || { - printf '%s\n' '{"nftables":[]}' - return - } - json_chain_name="$chain" - [ "$chain_json_mode" != wrong-chain-object ] || json_chain_name=wrong_chain - printf '{"nftables":[{"metainfo":{"version":"1.1.1","release_name":"Commodore Bullmoose #2","json_schema_version":1}},{"chain":{"family":"inet","table":"fw4","name":"%s","handle":1075}}' "$json_chain_name" - while IFS='|' read -r handle rule || [ -n "${handle:-}" ]; do - case "$rule" in - "insert rule inet fw4 $chain "*) ;; - *) continue ;; - esac - print_rule_json "$handle" "$rule" - done <"$state" - printf ']}\n' -} - -target_chain() { - case "$1" in - openclash_mangle_output|openclash_output|openclash_mangle|openclash) return 0 ;; - *) return 1 ;; - esac -} - -delete_rule_from_state() { - state_file="$1" - chain="$2" - handle="$3" - temp_file="$(mktemp "${state_file}.XXXXXX")" - found=0 - while IFS='|' read -r stored_handle stored_rule || [ -n "${stored_handle:-}" ]; do - if [ "$stored_handle" = "$handle" ]; then - case "$stored_rule" in - "insert rule inet fw4 $chain "*) found=1; continue ;; - esac - fi - printf '%s|%s\n' "$stored_handle" "$stored_rule" >>"$temp_file" - done <"$state_file" - if [ "$found" -ne 1 ]; then - printf 'nonexistent nft handle: %s/%s\n' "$chain" "$handle" >&2 - rm -f "$temp_file" - return 1 - fi - mv "$temp_file" "$state_file" -} - -delete_counter_from_state() { - state_file="$1" - counter="$2" - temp_file="$(mktemp "${state_file}.XXXXXX")" - found=0 - while IFS= read -r stored_counter || [ -n "$stored_counter" ]; do - if [ "$stored_counter" = "$counter" ]; then - found=$((found + 1)) - continue - fi - printf '%s\n' "$stored_counter" >>"$temp_file" - done <"$state_file" - if [ "$found" -ne 1 ]; then - printf 'nonexistent nft counter: %s\n' "$counter" >&2 - rm -f "$temp_file" - return 1 - fi - mv "$temp_file" "$state_file" -} - -replace_table_generation() { - batch="$1" - replacement="$(mktemp "${state}.replacement.XXXXXX")" - while IFS= read -r statement || [ -n "$statement" ]; do - case "$statement" in - 'delete rule inet fw4 '*) - rest="${statement#delete rule inet fw4 }" - chain="${rest%% handle *}" - handle="${rest##* handle }" - printf '%s|insert rule inet fw4 %s counter return comment "unrelated-generation-rule-%s"\n' \ - "$handle" "$chain" "$handle" >>"$replacement" - ;; - esac - done <"$batch" - mv "$replacement" "$state" - : >"$guard_state" -} - -apply_batch() { - batch="$1" - next_state="$(mktemp "${state}.next.XXXXXX")" - next_guard_state="$(mktemp "${guard_state}.next.XXXXXX")" - cp "$state" "$next_state" - cp "$guard_state" "$next_guard_state" - operation=0 - while IFS= read -r statement || [ -n "$statement" ]; do - operation=$((operation + 1)) - case "$statement" in - 'delete counter inet fw4 '*) - counter="${statement#delete counter inet fw4 }" - if ! delete_counter_from_state "$next_guard_state" "$counter"; then - rm -f "$next_state" "$next_guard_state" - return 1 - fi - ;; - 'delete rule inet fw4 '*) - rest="${statement#delete rule inet fw4 }" - chain="${rest%% handle *}" - handle="${rest##* handle }" - if ! delete_rule_from_state "$next_state" "$chain" "$handle"; then - rm -f "$next_state" "$next_guard_state" - return 1 - fi - ;; - 'insert rule inet fw4 '*) - next_handle="$(awk -F '|' 'BEGIN { max = 0 } $1 + 0 > max { max = $1 + 0 } END { print max + 1 }' "$next_state")" - printf '%s|%s\n' "$next_handle" "$statement" >>"$next_state" - ;; - *) - printf 'unsupported fake nft statement: %s\n' "$statement" >&2 - rm -f "$next_state" "$next_guard_state" - exit 99 - ;; - esac - if [ "${NFT_BATCH_FAIL_AFTER:-0}" = "$operation" ]; then - rm -f "$next_state" "$next_guard_state" - exit 1 - fi - done <"$batch" - mv "$next_state" "$state" - mv "$next_guard_state" "$guard_state" -} - -print_table_json() { - printf '{"nftables":[{"table":{"family":"inet","name":"fw4"}}' - for chain in openclash_mangle_output openclash_output openclash_mangle openclash; do - [ "${MISSING_CHAIN:-}" = "$chain" ] && continue - if [ -s "${NFT_DISAPPEARED_FILE:?}" ] && grep -Fx "$chain" "$NFT_DISAPPEARED_FILE" >/dev/null; then - continue - fi - printf ',{"chain":{"family":"inet","table":"fw4","name":"%s"}}' "$chain" - done - while IFS='|' read -r handle rule || [ -n "${handle:-}" ]; do - rest="${rule#insert rule inet fw4 }" - chain="${rest%% *}" - [ "${MISSING_CHAIN:-}" = "$chain" ] && continue - if [ -s "${NFT_DISAPPEARED_FILE:?}" ] && grep -Fx "$chain" "$NFT_DISAPPEARED_FILE" >/dev/null; then - continue - fi - print_rule_json "$handle" "$rule" - done <"$state" - printf ']}\n' -} - -print_table_inventory_json() { - case "$table_state" in - TABLE_ABSENT) - printf '%s\n' '{"nftables":[{"table":{"family":"inet","name":"other"}}]}' - ;; - *) - printf '%s\n' '{"nftables":[{"table":{"family":"inet","name":"fw4"}}]}' - ;; - esac -} - -if [ "$#" = 3 ] && [ "$1" = '-j' ] && [ "$2" = 'list' ] && [ "$3" = 'tables' ]; then - case "$table_state" in - INVENTORY_READ_ERROR) exit 73 ;; - INVENTORY_MALFORMED) printf '%s\n' 'not-json'; exit 0 ;; - esac - if [ "${REFRESH_INVENTORY_ERROR:-0}" = 1 ] && \ - [ "$(grep -Fxc -- '-j list tables' "$call_log")" -gt 1 ]; then - exit 73 - fi - print_table_inventory_json - exit 0 -fi - -if [ "$#" = 5 ] && [ "$1" = '-j' ] && [ "$2" = 'list' ] && [ "$3" = 'table' ] && \ - [ "$4" = 'inet' ] && [ "$5" = 'fw4' ]; then - case "$table_state" in - TABLE_ABSENT|TABLE_READ_ERROR|INVENTORY_READ_ERROR|INVENTORY_MALFORMED) exit 72 ;; - TABLE_MALFORMED) printf '%s\n' 'not-json'; exit 0 ;; - esac - if [ "${NFT_JSON_UNSUPPORTED:-0}" = 1 ]; then - printf 'not-json\n' - else - print_table_json - fi - exit 0 -fi - -if [ "$#" = 7 ] && [ "$1" = '-j' ] && [ "$2" = '-a' ] && [ "$3" = 'list' ] && \ - [ "$4" = 'chain' ] && [ "$5" = 'inet' ] && [ "$6" = 'fw4' ]; then - chain="$7" - [ "$table_state" = PRESENT ] || exit 72 - target_chain "$chain" || { - printf 'unsupported fake nft chain: %s\n' "$chain" >&2 - exit 99 - } - [ "${MISSING_CHAIN:-}" = "$chain" ] && exit 1 - if [ "${DISAPPEAR_CHAIN:-}" = "$chain" ] && ! grep -Fx "$chain" "${NFT_DISAPPEARED_FILE:?}" >/dev/null 2>&1; then - printf '%s\n' "$chain" >"$NFT_DISAPPEARED_FILE" - exit 1 - fi - grep -Fx "$chain" "${NFT_DISAPPEARED_FILE:?}" >/dev/null 2>&1 && exit 1 - [ "${LIST_CHAIN_FAIL:-}" = "$chain" ] && exit 1 - if [ "${NFT_JSON_UNSUPPORTED:-0}" = 1 ]; then - printf 'not-json\n' - else - print_chain_json "$chain" - fi - exit 0 -fi - -if [ "$#" = 5 ] && [ "$1" = 'add' ] && [ "$2" = 'counter' ] && \ - [ "$3" = 'inet' ] && [ "$4" = 'fw4' ]; then - [ "$table_state" = PRESENT ] || exit 72 - grep -Fx "$5" "$guard_state" >/dev/null 2>&1 && exit 1 - printf '%s\n' "$5" >>"$guard_state" - exit 0 -fi - -if [ "$#" = 5 ] && [ "$1" = 'delete' ] && [ "$2" = 'counter' ] && \ - [ "$3" = 'inet' ] && [ "$4" = 'fw4' ]; then - [ "$table_state" = PRESENT ] || exit 72 - if [ "${NFT_GUARD_DELETE_FAIL_ONCE:-0}" = 1 ] && \ - [ ! -e "${NFT_GUARD_DELETE_FAIL_ONCE_FILE:?}" ]; then - : >"$NFT_GUARD_DELETE_FAIL_ONCE_FILE" - exit 71 - fi - if [ "${NFT_GUARD_DELETE_SIGNAL_ONCE:-0}" = 1 ] && \ - [ ! -e "${NFT_GUARD_DELETE_SIGNAL_ONCE_FILE:?}" ]; then - : >"$NFT_GUARD_DELETE_SIGNAL_ONCE_FILE" - kill -TERM "$PPID" - exit 1 - fi - delete_counter_from_state "$guard_state" "$5" - exit $? -fi - -if [ "$#" = 2 ] && [ "$1" = '-f' ]; then - cat "$2" >"$batch_log" - [ "${NFT_BATCH_FAIL:-0}" = 1 ] && exit 1 - if [ "${NFT_SIGNAL_PARENT:-0}" = 1 ]; then - kill -TERM "$PPID" - exit 1 - fi - if [ "${NFT_GENERATION_REPLACE:-0}" = 1 ]; then - replace_table_generation "$2" - fi - apply_batch "$2" - exit 0 -fi - -printf 'unsupported fake nft command: %s\n' "$*" >&2 -exit 99 -SH -chmod +x "$TMP_DIR/bin/nft" -touch "$TMP_DIR/openclash-init" -chmod +x "$TMP_DIR/openclash-init" -HOOK="$TMP_DIR/openclash/custom/openclash_custom_firewall_rules.sh" -cat >"$HOOK" <<'EOF' -#!/bin/sh -printf user-before -printf '%s\n' 'user text mentions # BEGIN luci-app-tailscale 托管:Tailscale 绕过 OpenClash without owning it' -printf user-after -EOF -chmod 6750 "$HOOK" -original_owner="$(file_owner "$HOOK")" -original_hash="$(sha256sum "$HOOK" | awk '{print $1}')" -CHOWN_LOG="$TMP_DIR/chown.log" -REPLACEMENT_LOG="$TMP_DIR/replacement.log" -DD_LOG="$TMP_DIR/dd.log" -NFT_STATE="$TMP_DIR/nft.state" -NFT_GUARD_STATE="$TMP_DIR/nft.guards" -NFT_BATCH_LOG="$TMP_DIR/nft.batch" -NFT_CALL_LOG="$TMP_DIR/nft.calls" -NFT_DISAPPEARED_FILE="$TMP_DIR/nft.disappeared" -NFT_GUARD_DELETE_FAIL_ONCE_FILE="$TMP_DIR/nft.guard-delete-fail-once" -NFT_GUARD_DELETE_SIGNAL_ONCE_FILE="$TMP_DIR/nft.guard-delete-signal-once" -UCI_LOG="$TMP_DIR/uci.log" -LOGGER_LOG="$TMP_DIR/logger.log" -: >"$NFT_STATE" -: >"$NFT_GUARD_STATE" -: >"$NFT_BATCH_LOG" -: >"$NFT_CALL_LOG" -: >"$NFT_DISAPPEARED_FILE" -: >"$UCI_LOG" -: >"$LOGGER_LOG" -export CHOWN_LOG REPLACEMENT_LOG DD_LOG REAL_CHOWN REAL_CHMOD REAL_DD REAL_GREP -export NFT_STATE NFT_GUARD_STATE NFT_BATCH_LOG NFT_CALL_LOG NFT_DISAPPEARED_FILE UCI_LOG LOGGER_LOG -export NFT_GUARD_DELETE_FAIL_ONCE_FILE NFT_GUARD_DELETE_SIGNAL_ONCE_FILE - -run_helper() { - OPENCLASH_INIT="$TMP_DIR/openclash-init" \ - OPENCLASH_HOOK_FILE="$HOOK" \ - LOCK_FILE="$TMP_DIR/lock" \ - UCI_BIN="$TMP_DIR/bin/uci" \ - NFT_BIN="$TMP_DIR/bin/nft" \ - JQ_BIN=jq \ - LOGGER_CMD="$TMP_DIR/bin/logger" \ - TMPDIR="$TMP_DIR/tmp" \ - DD_FAIL_PREFIX="${DD_FAIL_PREFIX:-0}" \ - DD_FAIL_PROBE="${DD_FAIL_PROBE:-0}" \ - DD_SIGNAL_PARENT="${DD_SIGNAL_PARENT:-0}" \ - FEATURE_ENABLED="${FEATURE_ENABLED:-}" \ - UCI_SECTION_MODE="${UCI_SECTION_MODE:-present}" \ - UCI_OPTION_GET_FAIL="${UCI_OPTION_GET_FAIL:-0}" \ - NFT_TABLE_STATE="${NFT_TABLE_STATE:-PRESENT}" \ - REFRESH_INVENTORY_ERROR="${REFRESH_INVENTORY_ERROR:-0}" \ - NFT_RULE_JSON_MODE="${NFT_RULE_JSON_MODE:-canonical}" \ - NFT_CHAIN_JSON_MODE="${NFT_CHAIN_JSON_MODE:-canonical}" \ - NFT_CHAIN_JSON_TARGET="${NFT_CHAIN_JSON_TARGET:-}" \ - NFT_GUARD_DELETE_FAIL_ONCE="${NFT_GUARD_DELETE_FAIL_ONCE:-0}" \ - NFT_GUARD_DELETE_SIGNAL_ONCE="${NFT_GUARD_DELETE_SIGNAL_ONCE:-0}" \ - NFT_COUNTER_PACKETS="${NFT_COUNTER_PACKETS:-0}" \ - NFT_COUNTER_BYTES="${NFT_COUNTER_BYTES:-0}" \ - PATH="$TMP_DIR/bin:$PATH" \ - "$SCRIPT" "$@" -} - -failed_reconcile_hash="$(sha256sum "$HOOK" | awk '{print $1}')" -failed_reconcile_mode="$(file_mode "$HOOK")" -failed_reconcile_owner="$(file_owner "$HOOK")" -failed_reconcile_permissions="$(file_permissions "$HOOK")" -: >"$DD_LOG" -if DD_FAIL_PREFIX=1 run_helper reconcile-hook; then - fail 'reconcile-hook must fail when prefix copy fails' -fi -DD_FAIL_PREFIX=0 -grep -F 'failed:' "$DD_LOG" >/dev/null || fail 'reconcile-hook did not execute the failing prefix copy' -[ "$(sha256sum "$HOOK" | awk '{print $1}')" = "$failed_reconcile_hash" ] || \ - fail 'reconcile-hook changed hook after prefix copy failure' -[ "$(file_mode "$HOOK")" = "$failed_reconcile_mode" ] || \ - fail 'reconcile-hook changed hook mode after prefix copy failure' -[ "$(file_owner "$HOOK")" = "$failed_reconcile_owner" ] || \ - fail 'reconcile-hook changed hook ownership after prefix copy failure' -[ "$(file_permissions "$HOOK")" = "$failed_reconcile_permissions" ] || \ - fail 'reconcile-hook changed special permission bits after prefix copy failure' - -run_helper reconcile-hook -assert_line_count 1 '# BEGIN luci-app-tailscale 托管:Tailscale 绕过 OpenClash' "$HOOK" -assert_line_count 1 '# END luci-app-tailscale 托管:Tailscale 绕过 OpenClash' "$HOOK" -grep -F 'printf user-before' "$HOOK" >/dev/null || fail 'hook insertion removed user content before the block' -grep -F 'printf user-after' "$HOOK" >/dev/null || fail 'hook insertion removed user content after the block' -[ "$(file_permissions "$HOOK")" = '-rwsr-s---' ] || \ - fail 'hook mode changed, including special permission bits' -grep -F 'chmod 6750 ' "$REPLACEMENT_LOG" >/dev/null || \ - fail 'replacement did not restore special permission bits numerically' -[ "$(file_owner "$HOOK")" = "$original_owner" ] || fail 'hook ownership changed' - -first_hash="$(sha256sum "$HOOK" | awk '{print $1}')" -run_helper reconcile-hook -[ "$(sha256sum "$HOOK" | awk '{print $1}')" = "$first_hash" ] || fail 'reconcile-hook is not idempotent' - -probe_reconcile_hash="$(sha256sum "$HOOK" | awk '{print $1}')" -probe_reconcile_permissions="$(file_permissions "$HOOK")" -: >"$DD_LOG" -if DD_FAIL_PROBE=1 run_helper reconcile-hook; then - fail 'reconcile-hook must fail when the optional-newline probe cannot be read' -fi -DD_FAIL_PROBE=0 -grep -F 'probe-failed:' "$DD_LOG" >/dev/null || fail 'reconcile-hook did not execute the failing newline probe' -[ "$(sha256sum "$HOOK" | awk '{print $1}')" = "$probe_reconcile_hash" ] || \ - fail 'reconcile-hook changed hook after newline probe failure' -[ "$(file_permissions "$HOOK")" = "$probe_reconcile_permissions" ] || \ - fail 'reconcile-hook changed hook mode after newline probe failure' - -sed 's#^[[:space:]]*/usr/sbin/tailscale_openclash_bypass apply$#\tprintf stale-managed-body#' "$HOOK" >"$TMP_DIR/stale-hook" -chmod 750 "$TMP_DIR/stale-hook" -mv "$TMP_DIR/stale-hook" "$HOOK" -run_helper reconcile-hook -grep -Fx ' /usr/sbin/tailscale_openclash_bypass apply' "$HOOK" >/dev/null || \ - fail 'reconcile-hook did not restore the canonical managed command' -grep -F 'stale-managed-body' "$HOOK" >/dev/null && \ - fail 'reconcile-hook retained a stale managed body' -grep -F 'user text mentions # BEGIN luci-app-tailscale' "$HOOK" >/dev/null || \ - fail 'reconcile-hook removed marker-like user text' - -cp "$HOOK" "$TMP_DIR/hook-before-malformed" -printf '%s\n' '# BEGIN luci-app-tailscale 托管:Tailscale 绕过 OpenClash' >>"$HOOK" -malformed_hash="$(sha256sum "$HOOK" | awk '{print $1}')" -if run_helper reconcile-hook >/dev/null 2>&1; then - fail 'duplicate managed markers must fail' -fi -[ "$(sha256sum "$HOOK" | awk '{print $1}')" = "$malformed_hash" ] || fail 'malformed hook changed after rejection' - -cp "$TMP_DIR/hook-before-malformed" "$HOOK" -probe_cleanup_hash="$(sha256sum "$HOOK" | awk '{print $1}')" -probe_cleanup_permissions="$(file_permissions "$HOOK")" -: >"$DD_LOG" -if DD_FAIL_PROBE=1 run_helper cleanup; then - fail 'cleanup must fail when the optional-newline probe cannot be read' -fi -DD_FAIL_PROBE=0 -grep -F 'probe-failed:' "$DD_LOG" >/dev/null || fail 'cleanup did not execute the failing newline probe' -[ "$(sha256sum "$HOOK" | awk '{print $1}')" = "$probe_cleanup_hash" ] || \ - fail 'cleanup changed hook after newline probe failure' -[ "$(file_permissions "$HOOK")" = "$probe_cleanup_permissions" ] || \ - fail 'cleanup changed hook mode after newline probe failure' - -failed_cleanup_hash="$(sha256sum "$HOOK" | awk '{print $1}')" -failed_cleanup_mode="$(file_mode "$HOOK")" -failed_cleanup_owner="$(file_owner "$HOOK")" -: >"$DD_LOG" -if DD_FAIL_PREFIX=1 run_helper cleanup; then - fail 'cleanup must fail when prefix copy fails' -fi -DD_FAIL_PREFIX=0 -grep -F 'failed:' "$DD_LOG" >/dev/null || fail 'cleanup did not execute the failing prefix copy' -[ "$(sha256sum "$HOOK" | awk '{print $1}')" = "$failed_cleanup_hash" ] || \ - fail 'cleanup changed hook after prefix copy failure' -[ "$(file_mode "$HOOK")" = "$failed_cleanup_mode" ] || \ - fail 'cleanup changed hook mode after prefix copy failure' -[ "$(file_owner "$HOOK")" = "$failed_cleanup_owner" ] || \ - fail 'cleanup changed hook ownership after prefix copy failure' - -: >"$CHOWN_LOG" -: >"$REPLACEMENT_LOG" -run_helper cleanup -assert_chown_owner "$original_owner" -assert_owner_before_mode -managed_hook_present "$HOOK" && fail 'cleanup left the managed hook block' -grep -F 'printf user-before' "$HOOK" >/dev/null || fail 'cleanup removed user content' -[ "$(sha256sum "$HOOK" | awk '{print $1}')" = "$original_hash" ] || \ - fail 'hook cleanup did not restore every original byte' -[ "$(file_mode "$HOOK")" = 750 ] || fail 'cleanup changed hook mode' -[ "$(file_owner "$HOOK")" = "$original_owner" ] || fail 'cleanup changed hook ownership' - -: >"$DD_LOG" -if DD_SIGNAL_PARENT=1 run_helper reconcile-hook >/dev/null 2>&1; then - fail 'reconcile-hook must fail when terminated during replacement' -fi -DD_SIGNAL_PARENT=0 -find "$TMP_DIR/openclash/custom" -name '.tailscale-openclash-hook.*' -print | grep . >/dev/null && \ - fail 'terminated hook replacement left a tracked temporary file' -grep -F 'signalled:' "$DD_LOG" >/dev/null || fail 'termination test did not reach hook replacement' - -printf '#!/bin/sh\nprintf user-without-trailing-newline' >"$HOOK" -chmod 740 "$HOOK" -no_newline_hash="$(sha256sum "$HOOK" | awk '{print $1}')" -run_helper reconcile-hook -run_helper cleanup -[ "$(sha256sum "$HOOK" | awk '{print $1}')" = "$no_newline_hash" ] || \ - fail 'cleanup did not restore a hook without a trailing newline' - -for special_mode in 4640 1750 1751 7777; do - printf '#!/bin/sh\nprintf user-special-mode-%s\n' "$special_mode" >"$HOOK" - chmod "$special_mode" "$HOOK" - special_hash="$(sha256sum "$HOOK" | awk '{print $1}')" - special_permissions="$(file_permissions "$HOOK")" - run_helper reconcile-hook - [ "$(file_permissions "$HOOK")" = "$special_permissions" ] || \ - fail "reconcile-hook changed special permission mode $special_mode" - run_helper cleanup - [ "$(sha256sum "$HOOK" | awk '{print $1}')" = "$special_hash" ] || \ - fail "cleanup did not restore bytes for special permission mode $special_mode" - [ "$(file_permissions "$HOOK")" = "$special_permissions" ] || \ - fail "cleanup changed special permission mode $special_mode" -done - -rm -f "$TMP_DIR/openclash-init" "$HOOK" -run_helper reconcile-hook -[ ! -e "$HOOK" ] || fail 'OpenClash-absent reconciliation created a custom script' - -touch "$TMP_DIR/openclash-init" -chmod +x "$TMP_DIR/openclash-init" -run_helper reconcile-hook -[ -x "$HOOK" ] || fail 'missing OpenClash custom script was not created executable' -sh -n "$HOOK" || fail 'created hook is not valid shell' - -absent_cleanup_hash="$(sha256sum "$HOOK" | awk '{print $1}')" -absent_cleanup_mode="$(file_mode "$HOOK")" -absent_cleanup_owner="$(file_owner "$HOOK")" -rm -f "$TMP_DIR/openclash-init" -: >"$CHOWN_LOG" -run_helper cleanup -[ "$(sha256sum "$HOOK" | awk '{print $1}')" = "$absent_cleanup_hash" ] || \ - fail 'cleanup when OpenClash is absent rewrote the managed hook' -[ "$(file_mode "$HOOK")" = "$absent_cleanup_mode" ] || \ - fail 'cleanup when OpenClash is absent changed hook mode' -[ "$(file_owner "$HOOK")" = "$absent_cleanup_owner" ] || \ - fail 'cleanup when OpenClash is absent changed hook ownership' -[ ! -s "$CHOWN_LOG" ] || fail 'cleanup when OpenClash is absent invoked chown' - -touch "$TMP_DIR/openclash-init" -chmod +x "$TMP_DIR/openclash-init" -: >"$NFT_STATE" -: >"$NFT_BATCH_LOG" -: >"$NFT_CALL_LOG" -: >"$UCI_LOG" - -run_helper apply -assert_count 1 'insert rule inet fw4 openclash_mangle_output ' "$NFT_BATCH_LOG" -assert_count 1 'insert rule inet fw4 openclash_output ' "$NFT_BATCH_LOG" -assert_count 1 'insert rule inet fw4 openclash_mangle iifname "tailscale0"' "$NFT_BATCH_LOG" -assert_count 1 'insert rule inet fw4 openclash iifname "tailscale0"' "$NFT_BATCH_LOG" -assert_count 1 '-j list tables' "$NFT_CALL_LOG" -assert_count 1 '-j list table inet fw4' "$NFT_CALL_LOG" -assert_line_count 1 '-j -a list chain inet fw4 openclash_mangle_output' "$NFT_CALL_LOG" -assert_line_count 1 '-j -a list chain inet fw4 openclash_output' "$NFT_CALL_LOG" -assert_line_count 1 '-j -a list chain inet fw4 openclash_mangle' "$NFT_CALL_LOG" -assert_line_count 1 '-j -a list chain inet fw4 openclash' "$NFT_CALL_LOG" -assert_count 1 '-f ' "$NFT_CALL_LOG" -guard_name="$(sed -n 's/^add counter inet fw4 //p' "$NFT_CALL_LOG")" -[ -n "$guard_name" ] || fail 'apply must create a named counter before caching owned handles' -[ "$(sed -n '1p' "$NFT_BATCH_LOG")" = "delete counter inet fw4 $guard_name" ] || \ - fail 'the guard deletion must be the first statement in the reconciliation batch' -[ ! -s "$NFT_GUARD_STATE" ] || fail 'successful reconciliation leaked its table-generation guard' -guard_add_line="$(grep -nFx "add counter inet fw4 $guard_name" "$NFT_CALL_LOG" | cut -d: -f1)" -first_chain_line="$(grep -nF -- '-j -a list chain inet fw4 ' "$NFT_CALL_LOG" | head -n 1 | cut -d: -f1)" -[ "$guard_add_line" -lt "$first_chain_line" ] || fail 'apply cached rule handles before creating its table-generation guard' -grep -F 'counter return comment "luci-app-tailscale: Tailscale 标记流量绕过 OpenClash(mangle output)"' "$NFT_BATCH_LOG" >/dev/null || \ - fail 'mangle output rule comment is not exact' -grep -F 'counter return comment "luci-app-tailscale: Tailscale 标记流量绕过 OpenClash(output)"' "$NFT_BATCH_LOG" >/dev/null || \ - fail 'output rule comment is not exact' -grep -F 'counter return comment "luci-app-tailscale: tailscale0 入站流量绕过 OpenClash(mangle)"' "$NFT_BATCH_LOG" >/dev/null || \ - fail 'mangle ingress rule comment is not exact' -grep -F 'counter return comment "luci-app-tailscale: tailscale0 入站流量绕过 OpenClash(filter)"' "$NFT_BATCH_LOG" >/dev/null || \ - fail 'filter ingress rule comment is not exact' - -run_helper apply -[ "$(grep -c 'insert rule inet fw4' "$NFT_STATE")" = 4 ] || fail 'repeated apply duplicated owned rules' - -hook_before_status="$(sha256sum "$HOOK" | awk '{print $1}')" -state_before_status="$(sha256sum "$NFT_STATE" | awk '{print $1}')" -: >"$NFT_BATCH_LOG" -printf '%s' "$(run_helper status)" | jq -e ' - .state == "active" and .enabled == true and .openclash_present == true and - .firewall4_supported == true and .hook == "managed" and .rules_present == 4 and - .message == "OpenClash bypass is active." -' >/dev/null || fail 'complete managed state did not report active' -[ ! -s "$NFT_BATCH_LOG" ] || fail 'status mutated nftables state' -[ "$(sha256sum "$HOOK" | awk '{print $1}')" = "$hook_before_status" ] || fail 'status rewrote the hook' -[ "$(sha256sum "$NFT_STATE" | awk '{print $1}')" = "$state_before_status" ] || fail 'status rewrote nftables state' - -sed 's#^[[:space:]]*/usr/sbin/tailscale_openclash_bypass apply$#\tprintf stale-status-hook#' "$HOOK" >"$TMP_DIR/stale-status-hook" -chmod 750 "$TMP_DIR/stale-status-hook" -mv "$TMP_DIR/stale-status-hook" "$HOOK" -printf '%s' "$(run_helper status)" | jq -e '.state == "error" and .hook == "error"' >/dev/null || \ - fail 'status accepted a noncanonical managed hook body' -run_helper reconcile-hook - -for invalid_rule_mode in altered-match overbroad incomplete altered-verdict wrong-chain; do - printf '%s' "$(NFT_RULE_JSON_MODE="$invalid_rule_mode" run_helper status)" | \ - jq -e '.state == "error" and .rules_present == 4' >/dev/null || \ - fail "status accepted noncanonical owned rule JSON mode $invalid_rule_mode" -done -NFT_RULE_JSON_MODE=canonical - -printf '%s' "$(NFT_COUNTER_PACKETS=123456 NFT_COUNTER_BYTES=987654321 run_helper status)" | \ - jq -e '.state == "active" and .rules_present == 4' >/dev/null || \ - fail 'dynamic counter values changed canonical rule validation' -NFT_COUNTER_PACKETS=0 -NFT_COUNTER_BYTES=0 - -cp "$NFT_STATE" "$TMP_DIR/nft-before-duplicate-status" -printf '%s\n' '901|insert rule inet fw4 openclash_mangle_output meta mark & 0x00ff0000 == 0x00080000 counter return comment "luci-app-tailscale: Tailscale 标记流量绕过 OpenClash(mangle output)"' >>"$NFT_STATE" -printf '%s' "$(run_helper status)" | jq -e '.state == "error" and .rules_present == 5' >/dev/null || \ - fail 'status accepted a duplicated canonical owned rule' -mv "$TMP_DIR/nft-before-duplicate-status" "$NFT_STATE" - -cp "$NFT_STATE" "$TMP_DIR/nft-before-unexpected-status" -printf '%s\n' '902|insert rule inet fw4 openclash iifname "tailscale0" counter return comment "luci-app-tailscale: unexpected owned rule"' >>"$NFT_STATE" -printf '%s' "$(run_helper status)" | jq -e '.state == "error" and .rules_present == 5' >/dev/null || \ - fail 'status ignored an unexpected package-owned rule' -mv "$TMP_DIR/nft-before-unexpected-status" "$NFT_STATE" - -for malformed_chain_mode in empty wrong-chain-object wrong-rule-chain missing-owned-handle; do - : >"$NFT_BATCH_LOG" - malformed_chain_state_hash="$(sha256sum "$NFT_STATE" | awk '{print $1}')" - malformed_chain_hook_hash="$(sha256sum "$HOOK" | awk '{print $1}')" - if NFT_CHAIN_JSON_TARGET=openclash_output NFT_CHAIN_JSON_MODE="$malformed_chain_mode" run_helper apply; then - fail "apply accepted structurally invalid chain JSON mode $malformed_chain_mode" - fi - [ ! -s "$NFT_BATCH_LOG" ] || fail "invalid chain JSON mode $malformed_chain_mode submitted an apply batch" - [ "$(sha256sum "$NFT_STATE" | awk '{print $1}')" = "$malformed_chain_state_hash" ] || \ - fail "invalid chain JSON mode $malformed_chain_mode changed nft state during apply" - printf '%s' "$(NFT_CHAIN_JSON_TARGET=openclash_output NFT_CHAIN_JSON_MODE="$malformed_chain_mode" run_helper status)" | \ - jq -e '.state == "error"' >/dev/null || \ - fail "status accepted structurally invalid chain JSON mode $malformed_chain_mode" - if NFT_CHAIN_JSON_TARGET=openclash_output NFT_CHAIN_JSON_MODE="$malformed_chain_mode" run_helper cleanup; then - fail "cleanup accepted structurally invalid chain JSON mode $malformed_chain_mode" - fi - [ ! -s "$NFT_BATCH_LOG" ] || fail "invalid chain JSON mode $malformed_chain_mode submitted a cleanup batch" - [ "$(sha256sum "$NFT_STATE" | awk '{print $1}')" = "$malformed_chain_state_hash" ] || \ - fail "invalid chain JSON mode $malformed_chain_mode changed nft state during cleanup" - [ "$(sha256sum "$HOOK" | awk '{print $1}')" = "$malformed_chain_hook_hash" ] || \ - fail "invalid chain JSON mode $malformed_chain_mode removed the retry hook" -done -NFT_CHAIN_JSON_MODE=canonical -NFT_CHAIN_JSON_TARGET= - -awk '!/insert rule inet fw4 openclash_output /' "$NFT_STATE" >"$TMP_DIR/nft-partial-before-missing-chain" -mv "$TMP_DIR/nft-partial-before-missing-chain" "$NFT_STATE" -: >"$NFT_BATCH_LOG" -: >"$NFT_CALL_LOG" -MISSING_CHAIN=openclash_output run_helper apply -[ -s "$NFT_BATCH_LOG" ] || fail 'missing target chain did not remove partial owned rules' -grep -F 'insert rule inet fw4' "$NFT_BATCH_LOG" >/dev/null && \ - fail 'missing target chain attempted to install a partial owned rule set' -grep -F 'luci-app-tailscale:' "$NFT_STATE" >/dev/null && \ - fail 'missing target chain left partial owned rules in surviving chains' -printf '%s' "$(MISSING_CHAIN=openclash_output run_helper status)" | \ - jq -e '.state == "waiting" and .hook == "managed" and .rules_present == 0' >/dev/null || \ - fail 'missing chain did not report a residue-free waiting state' -MISSING_CHAIN= -run_helper apply -[ "$(grep -c 'comment "luci-app-tailscale:' "$NFT_STATE")" = 4 ] || \ - fail 'apply did not restore the complete rule set after the missing chain returned' - -: >"$NFT_DISAPPEARED_FILE" -: >"$NFT_BATCH_LOG" -: >"$NFT_CALL_LOG" -DISAPPEAR_CHAIN=openclash_output run_helper apply -[ -s "$NFT_BATCH_LOG" ] || \ - fail 'apply did not clean surviving owned rules after a target chain disappeared during inspection' -grep -F 'luci-app-tailscale:' "$NFT_STATE" | grep -Fv 'openclash_output ' >/dev/null && \ - fail 'apply left a partial owned rule set after a target chain disappeared during inspection' -printf '%s' "$(DISAPPEAR_CHAIN=openclash_output run_helper status)" | \ - jq -e '.state == "waiting" and .hook == "managed" and .rules_present == 0' >/dev/null || \ - fail 'apply-time chain disappearance did not report a residue-free waiting state' -DISAPPEAR_CHAIN= -: >"$NFT_DISAPPEARED_FILE" -: >"$NFT_STATE" -run_helper apply -[ "$(grep -c 'comment "luci-app-tailscale:' "$NFT_STATE")" = 4 ] || \ - fail 'apply did not restore the complete rule set after an inspected chain returned' - -: >"$NFT_BATCH_LOG" -: >"$NFT_CALL_LOG" -state_before_list_failure="$(sha256sum "$NFT_STATE" | awk '{print $1}')" -if LIST_CHAIN_FAIL=openclash_output run_helper apply; then - fail 'apply must fail when a table-enumerated chain cannot be listed' -fi -LIST_CHAIN_FAIL= -[ ! -s "$NFT_BATCH_LOG" ] || fail 'list-chain precheck failure wrote an nft batch' -assert_count 0 '-f ' "$NFT_CALL_LOG" -[ "$(sha256sum "$NFT_STATE" | awk '{print $1}')" = "$state_before_list_failure" ] || \ - fail 'list-chain precheck failure changed nft state' -printf '%s' "$(LIST_CHAIN_FAIL=openclash_output run_helper status)" | jq -e '.state == "error"' >/dev/null || \ - fail 'table-enumerated list-chain failure did not report error' -LIST_CHAIN_FAIL= - -: >"$NFT_GUARD_STATE" -: >"$NFT_CALL_LOG" -rm -f "$NFT_GUARD_DELETE_FAIL_ONCE_FILE" -if NFT_GUARD_DELETE_FAIL_ONCE=1 LIST_CHAIN_FAIL=openclash_output run_helper apply; then - fail 'apply must fail when a chain read and the first independent guard deletion fail' -fi -[ ! -s "$NFT_GUARD_STATE" ] || fail 'EXIT cleanup did not retry a failed independent guard deletion' -[ "$(grep -c '^delete counter inet fw4 ' "$NFT_CALL_LOG")" -ge 2 ] || \ - fail 'failed independent guard deletion was not retried by EXIT cleanup' -NFT_GUARD_DELETE_FAIL_ONCE=0 -LIST_CHAIN_FAIL= - -: >"$NFT_GUARD_STATE" -: >"$NFT_CALL_LOG" -rm -f "$NFT_GUARD_DELETE_SIGNAL_ONCE_FILE" -if NFT_GUARD_DELETE_SIGNAL_ONCE=1 LIST_CHAIN_FAIL=openclash_output run_helper apply >/dev/null 2>&1; then - fail 'apply must fail when terminated during independent guard deletion' -fi -[ ! -s "$NFT_GUARD_STATE" ] || fail 'termination during independent guard deletion leaked the tracked guard' -[ "$(grep -c '^delete counter inet fw4 ' "$NFT_CALL_LOG")" -ge 2 ] || \ - fail 'termination during independent guard deletion did not trigger EXIT cleanup' -NFT_GUARD_DELETE_SIGNAL_ONCE=0 -LIST_CHAIN_FAIL= - -: >"$NFT_BATCH_LOG" -state_before_failed_batch="$(sha256sum "$NFT_STATE" | awk '{print $1}')" -if NFT_BATCH_FAIL=1 run_helper apply; then - fail 'apply must fail when the nft batch fails' -fi -NFT_BATCH_FAIL=0 -[ ! -s "$NFT_GUARD_STATE" ] || fail 'failed reconciliation leaked its table-generation guard' -[ -s "$NFT_BATCH_LOG" ] || fail 'failed apply did not submit an nft batch' -[ "$(sha256sum "$NFT_STATE" | awk '{print $1}')" = "$state_before_failed_batch" ] || \ - fail 'failed nft batch changed state' -grep -F 'OpenClash nftables reconciliation transaction failed.' "$LOGGER_LOG" >/dev/null || \ - fail 'failed nft batch was not logged' - -: >"$NFT_BATCH_LOG" -: >"$NFT_CALL_LOG" -state_before_mid_batch_failure="$(sha256sum "$NFT_STATE" | awk '{print $1}')" -if NFT_BATCH_FAIL_AFTER=1 run_helper apply; then - fail 'apply must fail when fake nft fails after the first batch operation' -fi -NFT_BATCH_FAIL_AFTER=0 -[ -s "$NFT_BATCH_LOG" ] || fail 'mid-batch failure did not submit an nft batch' -assert_count 1 '-f ' "$NFT_CALL_LOG" -[ "$(sha256sum "$NFT_STATE" | awk '{print $1}')" = "$state_before_mid_batch_failure" ] || \ - fail 'mid-batch fake nft failure changed the primary state' - -if NFT_SIGNAL_PARENT=1 run_helper apply >/dev/null 2>&1; then - fail 'apply must fail when terminated during nft replacement' -fi -NFT_SIGNAL_PARENT=0 -find "$TMP_DIR/tmp" -name 'tailscale-openclash-nft.*' -print | grep . >/dev/null && \ - fail 'terminated nft reconciliation left a tracked temporary path' -[ ! -s "$NFT_GUARD_STATE" ] || fail 'terminated reconciliation leaked its table-generation guard' - -: >"$NFT_BATCH_LOG" -if NFT_GENERATION_REPLACE=1 run_helper apply >/dev/null 2>&1; then - fail 'apply must fail when inet fw4 is replaced after owned handles are cached' -fi -NFT_GENERATION_REPLACE=0 -[ "$(grep -c 'unrelated-generation-rule-' "$NFT_STATE")" = 4 ] || \ - fail 'table replacement transaction deleted unrelated rules that reused cached handles' -[ ! -s "$NFT_GUARD_STATE" ] || fail 'table replacement failure leaked its stale guard state' -run_helper apply -[ "$(grep -c 'comment "luci-app-tailscale:' "$NFT_STATE")" = 4 ] || \ - fail 'apply did not recover after the deterministic table replacement' -[ "$(grep -c 'unrelated-generation-rule-' "$NFT_STATE")" = 4 ] || \ - fail 'recovery after table replacement removed unrelated rules' - -for false_alias in 0 false off no disabled; do - FEATURE_ENABLED=1 run_helper sync - FEATURE_ENABLED="$false_alias" run_helper sync - grep -F 'luci-app-tailscale:' "$NFT_STATE" >/dev/null && \ - fail "false alias $false_alias left owned nft rules" - managed_hook_present "$HOOK" && \ - fail "false alias $false_alias left the managed hook" - printf '%s' "$(FEATURE_ENABLED="$false_alias" run_helper status)" | \ - jq -e '.state == "disabled" and .enabled == false and .hook == "absent" and .rules_present == 0' >/dev/null || \ - fail "false alias $false_alias did not report disabled" -done - -: >"$UCI_LOG" -FEATURE_ENABLED= UCI_SECTION_MODE=present run_helper sync -assert_line_count 1 '-q show tailscale_openclash.settings' "$UCI_LOG" -assert_line_count 0 '-q get tailscale_openclash.settings.enabled' "$UCI_LOG" -printf '%s' "$(FEATURE_ENABLED= UCI_SECTION_MODE=present run_helper status)" | \ - jq -e '.state == "active" and .enabled == true' >/dev/null || \ - fail 'a verified absent enabled option did not use the enabled default' - -for uci_failure in missing error malformed; do - printf '#!/bin/sh\nprintf user-only\n' >"$HOOK" - : >"$NFT_STATE" - : >"$NFT_BATCH_LOG" - uci_failure_hook_hash="$(sha256sum "$HOOK" | awk '{print $1}')" - uci_failure_state_hash="$(sha256sum "$NFT_STATE" | awk '{print $1}')" - if UCI_SECTION_MODE="$uci_failure" FEATURE_ENABLED=1 run_helper sync; then - fail "UCI section state $uci_failure must make sync fail" - fi - [ "$(sha256sum "$HOOK" | awk '{print $1}')" = "$uci_failure_hook_hash" ] || \ - fail "UCI section state $uci_failure changed the managed hook" - [ "$(sha256sum "$NFT_STATE" | awk '{print $1}')" = "$uci_failure_state_hash" ] || \ - fail "UCI section state $uci_failure changed nft state" - [ ! -s "$NFT_BATCH_LOG" ] || fail "UCI section state $uci_failure submitted an nft batch" - printf '%s' "$(UCI_SECTION_MODE="$uci_failure" FEATURE_ENABLED=1 run_helper status)" | \ - jq -e '.state == "error" and .enabled == false' >/dev/null || \ - fail "UCI section state $uci_failure did not report error" -done -UCI_SECTION_MODE=present - -printf '#!/bin/sh\nprintf user-only\n' >"$HOOK" -: >"$NFT_STATE" -: >"$NFT_BATCH_LOG" -uci_get_failure_hook_hash="$(sha256sum "$HOOK" | awk '{print $1}')" -uci_get_failure_state_hash="$(sha256sum "$NFT_STATE" | awk '{print $1}')" -if UCI_OPTION_GET_FAIL=1 FEATURE_ENABLED=1 run_helper sync; then - fail 'an operational failure reading a present enabled option must make sync fail' -fi -[ "$(sha256sum "$HOOK" | awk '{print $1}')" = "$uci_get_failure_hook_hash" ] || \ - fail 'an enabled-option read failure changed the managed hook' -[ "$(sha256sum "$NFT_STATE" | awk '{print $1}')" = "$uci_get_failure_state_hash" ] || \ - fail 'an enabled-option read failure changed nft state' -[ ! -s "$NFT_BATCH_LOG" ] || fail 'an enabled-option read failure submitted an nft batch' -printf '%s' "$(UCI_OPTION_GET_FAIL=1 FEATURE_ENABLED=1 run_helper status)" | \ - jq -e '.state == "error" and .enabled == false' >/dev/null || \ - fail 'an enabled-option read failure did not report error' -UCI_OPTION_GET_FAIL=0 - -printf '#!/bin/sh\nprintf user-only\n' >"$HOOK" -: >"$NFT_STATE" -: >"$NFT_BATCH_LOG" -: >"$NFT_CALL_LOG" -unsupported_hook_hash="$(sha256sum "$HOOK" | awk '{print $1}')" -unsupported_state_hash="$(sha256sum "$NFT_STATE" | awk '{print $1}')" -NFT_TABLE_STATE=TABLE_ABSENT FEATURE_ENABLED=1 run_helper sync -[ "$(sha256sum "$HOOK" | awk '{print $1}')" = "$unsupported_hook_hash" ] || \ - fail 'unsupported firewall4 sync changed the OpenClash hook' -[ "$(sha256sum "$NFT_STATE" | awk '{print $1}')" = "$unsupported_state_hash" ] || \ - fail 'unsupported firewall4 sync changed nft state' -[ ! -s "$NFT_BATCH_LOG" ] || fail 'unsupported firewall4 sync submitted an nft batch' -assert_line_count 1 '-j list tables' "$NFT_CALL_LOG" -assert_line_count 0 '-j list table inet fw4' "$NFT_CALL_LOG" -printf '%s' "$(NFT_TABLE_STATE=TABLE_ABSENT FEATURE_ENABLED=1 run_helper status)" | jq -e '.state == "unsupported"' >/dev/null || \ - fail 'missing firewall4 table did not report unsupported' - -: >"$NFT_BATCH_LOG" -: >"$NFT_CALL_LOG" -NFT_TABLE_STATE=TABLE_ABSENT FEATURE_ENABLED=1 run_helper apply -[ ! -s "$NFT_BATCH_LOG" ] || fail 'verified absent firewall4 apply submitted an nft batch' -assert_line_count 1 '-j list tables' "$NFT_CALL_LOG" -assert_line_count 0 '-j list table inet fw4' "$NFT_CALL_LOG" - -for table_failure in INVENTORY_READ_ERROR INVENTORY_MALFORMED TABLE_READ_ERROR TABLE_MALFORMED; do - printf '#!/bin/sh\nprintf user-only\n' >"$HOOK" - : >"$NFT_STATE" - for helper_command in sync apply cleanup; do - : >"$NFT_BATCH_LOG" - table_failure_hook_hash="$(sha256sum "$HOOK" | awk '{print $1}')" - table_failure_state_hash="$(sha256sum "$NFT_STATE" | awk '{print $1}')" - if NFT_TABLE_STATE="$table_failure" FEATURE_ENABLED=1 run_helper "$helper_command"; then - fail "$helper_command must fail for nft table state $table_failure" - fi - [ "$(sha256sum "$HOOK" | awk '{print $1}')" = "$table_failure_hook_hash" ] || \ - fail "$helper_command changed the hook for nft table state $table_failure" - [ "$(sha256sum "$NFT_STATE" | awk '{print $1}')" = "$table_failure_state_hash" ] || \ - fail "$helper_command changed nft state for nft table state $table_failure" - [ ! -s "$NFT_BATCH_LOG" ] || \ - fail "$helper_command submitted an nft batch for nft table state $table_failure" - done - printf '%s' "$(NFT_TABLE_STATE="$table_failure" FEATURE_ENABLED=1 run_helper status)" | \ - jq -e '.state == "error"' >/dev/null || \ - fail "nft table state $table_failure did not report error" -done -NFT_TABLE_STATE=PRESENT - -rm -f "$TMP_DIR/openclash-init" -absent_hook_hash="$(sha256sum "$HOOK" | awk '{print $1}')" -FEATURE_ENABLED=1 run_helper sync -[ "$(sha256sum "$HOOK" | awk '{print $1}')" = "$absent_hook_hash" ] || \ - fail 'OpenClash-absent sync changed the hook' -touch "$TMP_DIR/openclash-init" -chmod +x "$TMP_DIR/openclash-init" - -: >"$NFT_BATCH_LOG" -MISSING_CHAIN=openclash_output FEATURE_ENABLED=1 run_helper sync -grep -Fx '# BEGIN luci-app-tailscale 托管:Tailscale 绕过 OpenClash' "$HOOK" >/dev/null || \ - fail 'waiting sync did not install the canonical hook' -grep -Fx ' /usr/sbin/tailscale_openclash_bypass apply' "$HOOK" >/dev/null || \ - fail 'waiting sync did not install the canonical managed command' -[ ! -s "$NFT_BATCH_LOG" ] || fail 'waiting sync submitted a partial nft batch' -printf '%s' "$(MISSING_CHAIN=openclash_output FEATURE_ENABLED=1 run_helper status)" | \ - jq -e '.state == "waiting" and .hook == "managed" and .rules_present == 0' >/dev/null || \ - fail 'waiting sync did not report a canonical managed hook' -MISSING_CHAIN= - -FEATURE_ENABLED=true run_helper sync -printf '%s' "$(FEATURE_ENABLED=true run_helper status)" | jq -e '.state == "active" and .rules_present == 4' >/dev/null || \ - fail 'sync did not produce active state for a true alias' - -FEATURE_ENABLED=0 run_helper sync -: >"$UCI_LOG" -sync_ready="$TMP_DIR/sync-ready" -sync_release="$TMP_DIR/sync-release" -rm -f "$sync_ready" "$sync_release" -UCI_READY_FILE="$sync_ready" UCI_RELEASE_FILE="$sync_release" FEATURE_ENABLED=1 run_helper sync & -SYNC_ENABLE_PID=$! -attempt=0 -while [ ! -e "$sync_ready" ]; do - attempt=$((attempt + 1)) - [ "$attempt" -lt 100 ] || fail 'enabled sync did not reach the locked UCI read' - sleep 0.05 -done -FEATURE_ENABLED=0 run_helper sync & -SYNC_DISABLE_PID=$! -sleep 0.2 -[ "$(grep -c '^-q get tailscale_openclash.settings.enabled$' "$UCI_LOG")" = 1 ] || \ - fail 'concurrent disabled sync passed the real flock before enabled sync completed' -: >"$sync_release" -wait "$SYNC_ENABLE_PID" || fail 'locked enabled sync failed' -SYNC_ENABLE_PID= -wait "$SYNC_DISABLE_PID" || fail 'serialized disabled sync failed' -SYNC_DISABLE_PID= -[ "$(grep -c '^-q get tailscale_openclash.settings.enabled$' "$UCI_LOG")" = 2 ] || \ - fail 'both serialized sync commands must re-read UCI under the lock' -grep -F 'luci-app-tailscale:' "$NFT_STATE" >/dev/null && \ - fail 'serialized disable left owned nft rules' -managed_hook_present "$HOOK" && \ - fail 'serialized disable left the managed hook' -FEATURE_ENABLED= - -: >"$NFT_BATCH_LOG" -: >"$NFT_CALL_LOG" -run_helper apply -: >"$NFT_BATCH_LOG" -: >"$NFT_CALL_LOG" -if LIST_CHAIN_FAIL=openclash_output run_helper cleanup; then - fail 'cleanup must fail when a table-enumerated chain cannot be listed' -fi -LIST_CHAIN_FAIL= -[ ! -s "$NFT_BATCH_LOG" ] || fail 'cleanup list-chain failure wrote an nft batch' -assert_count 0 '-f ' "$NFT_CALL_LOG" -grep -F 'luci-app-tailscale:' "$NFT_STATE" >/dev/null || fail 'list-chain cleanup failure changed owned nft rules' -run_helper reconcile-hook -if NFT_BATCH_FAIL=1 run_helper cleanup; then - fail 'cleanup must fail when the nft delete batch fails' -fi -NFT_BATCH_FAIL=0 -grep -F 'luci-app-tailscale:' "$NFT_STATE" >/dev/null || fail 'failed cleanup batch changed owned nft rules' -managed_hook_present "$HOOK" || fail 'failed nft cleanup must retain the managed hook for retry' -printf '%s' "$(FEATURE_ENABLED=0 run_helper status)" | jq -e ' - .state == "error" and .enabled == false and .hook == "managed" and - .rules_present == 4 -' >/dev/null || fail 'disabled status did not expose retained cleanup artifacts' - -FEATURE_ENABLED=0 run_helper apply -grep -F 'luci-app-tailscale:' "$NFT_STATE" >/dev/null && fail 'cleanup retry left owned nft rules' -managed_hook_present "$HOOK" && fail 'cleanup retry left the managed hook' -printf '%s' "$(FEATURE_ENABLED=0 run_helper status)" | jq -e ' - .state == "disabled" and .enabled == false and .hook == "absent" and - .rules_present == 0 -' >/dev/null || fail 'successful cleanup retry did not report a residue-free disabled state' - -FEATURE_ENABLED=1 run_helper sync -printf '%s\n' '900|insert rule inet fw4 openclash iifname "tailscale0" counter return comment "user-owned-rule"' >>"$NFT_STATE" -run_helper cleanup -grep -F 'luci-app-tailscale:' "$NFT_STATE" >/dev/null && fail 'cleanup left owned nft rules' -grep -F 'user-owned-rule' "$NFT_STATE" >/dev/null || fail 'cleanup removed a user-owned rule' -managed_hook_present "$HOOK" && fail 'cleanup left the managed hook block' - -FEATURE_ENABLED=1 run_helper sync -: >"$NFT_BATCH_LOG" -NFT_TABLE_STATE=TABLE_ABSENT run_helper cleanup -NFT_TABLE_STATE=PRESENT -[ ! -s "$NFT_BATCH_LOG" ] || fail 'cleanup with an absent fw4 table submitted an nft batch' -: >"$NFT_STATE" - -FEATURE_ENABLED=1 run_helper sync -awk '!/insert rule inet fw4 openclash_output /' "$NFT_STATE" >"$TMP_DIR/nft-with-missing-chain" -mv "$TMP_DIR/nft-with-missing-chain" "$NFT_STATE" -MISSING_CHAIN=openclash_output run_helper cleanup -MISSING_CHAIN= -grep -F 'luci-app-tailscale:' "$NFT_STATE" >/dev/null && \ - fail 'cleanup with an already absent target chain left rules from existing chains' - -FEATURE_ENABLED=1 run_helper sync -: >"$NFT_DISAPPEARED_FILE" -: >"$NFT_CALL_LOG" -DISAPPEAR_CHAIN=openclash_output run_helper cleanup -DISAPPEAR_CHAIN= -[ "$(grep -c '^-j list table inet fw4$' "$NFT_CALL_LOG")" -ge 2 ] || \ - fail 'cleanup did not refresh table state after a chain disappeared' -: >"$NFT_STATE" -: >"$NFT_DISAPPEARED_FILE" - -printf '%s' "$(run_helper status)" | jq -e '.state == "error"' >/dev/null || \ - fail 'incomplete managed state did not report error' - -rm -f "$HOOK" -printf '%s' "$(MISSING_CHAIN=openclash_output run_helper status)" | jq -e '.state == "waiting" and .hook == "absent"' >/dev/null || \ - fail 'missing hook and target chain did not report waiting' -MISSING_CHAIN= -mkdir "$HOOK" -printf '%s' "$(run_helper status)" | jq -e '.state == "error" and .hook == "error"' >/dev/null || \ - fail 'hook read failure did not report error' -rmdir "$HOOK" - -printf '%s\n' '# BEGIN luci-app-tailscale 托管:Tailscale 绕过 OpenClash' >>"$HOOK" -printf '%s' "$(run_helper status)" | jq -e '.state == "error"' >/dev/null || \ - fail 'malformed markers did not report error' -printf '%s' "$(NFT_TABLE_STATE=TABLE_ABSENT FEATURE_ENABLED=0 run_helper status)" | \ - jq -e '.state == "error" and .enabled == false and .hook == "error"' >/dev/null || \ - fail 'verified absent firewall4 table hid a malformed disabled hook' -printf '%s' "$(NFT_TABLE_STATE=TABLE_ABSENT UCI_SECTION_MODE=error FEATURE_ENABLED=1 run_helper status)" | \ - jq -e '.state == "error" and .enabled == false' >/dev/null || \ - fail 'verified absent firewall4 table hid an operational UCI failure' -printf '%s' "$(NFT_JSON_UNSUPPORTED=1 FEATURE_ENABLED=0 run_helper status)" | jq -e '.state == "error"' >/dev/null || \ - fail 'malformed nft JSON did not report error' -rm -f "$HOOK" -printf '%s' "$(NFT_TABLE_STATE=TABLE_ABSENT FEATURE_ENABLED=0 run_helper status)" | \ - jq -e '.state == "disabled" and .enabled == false and .firewall4_supported == false and .hook == "absent" and .rules_present == 0' >/dev/null || \ - fail 'disabled state with no fw4 table and no hook did not report cleanly disabled' -run_helper reconcile-hook -printf '%s' "$(NFT_TABLE_STATE=TABLE_ABSENT FEATURE_ENABLED=0 run_helper status)" | \ - jq -e '.state == "error" and .enabled == false and .firewall4_supported == false and .hook == "managed" and .rules_present == 0' >/dev/null || \ - fail 'disabled state with no fw4 table hid a retained managed hook' -NFT_TABLE_STATE=TABLE_ABSENT run_helper cleanup -rm -f "$TMP_DIR/openclash-init" -printf '%s' "$(NFT_TABLE_STATE=TABLE_ABSENT run_helper status)" | jq -e '.state == "absent"' >/dev/null || \ - fail 'OpenClash absence did not take status precedence' -grep -F 'firewall' "$UCI_LOG" >/dev/null && fail 'helper queried the firewall UCI package' - -printf '%s\n' 'tailscale OpenClash bypass tests passed' diff --git a/luci-app-tailscale/tests/tailscale_openclash_install_test.sh b/luci-app-tailscale/tests/tailscale_openclash_install_test.sh deleted file mode 100644 index 67450ff29..000000000 --- a/luci-app-tailscale/tests/tailscale_openclash_install_test.sh +++ /dev/null @@ -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" <"$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" diff --git a/luci-app-tailscale/tests/tailscale_openclash_lifecycle_test.sh b/luci-app-tailscale/tests/tailscale_openclash_lifecycle_test.sh deleted file mode 100755 index db7e12d7e..000000000 --- a/luci-app-tailscale/tests/tailscale_openclash_lifecycle_test.sh +++ /dev/null @@ -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" diff --git a/luci-app-tailscale/tests/tailscale_peer_probe_test.sh b/luci-app-tailscale/tests/tailscale_peer_probe_test.sh deleted file mode 100644 index be66adafa..000000000 --- a/luci-app-tailscale/tests/tailscale_peer_probe_test.sh +++ /dev/null @@ -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 "' "$missing_arg_output" - -echo "tailscale_peer_probe tests passed" diff --git a/luci-app-tailscale/tests/tailscale_policy_routing_lifecycle_test.sh b/luci-app-tailscale/tests/tailscale_policy_routing_lifecycle_test.sh deleted file mode 100755 index e396be09c..000000000 --- a/luci-app-tailscale/tests/tailscale_policy_routing_lifecycle_test.sh +++ /dev/null @@ -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' diff --git a/luci-app-tailscale/tests/tailscale_policy_routing_test.sh b/luci-app-tailscale/tests/tailscale_policy_routing_test.sh deleted file mode 100755 index ac0f92ab7..000000000 --- a/luci-app-tailscale/tests/tailscale_policy_routing_test.sh +++ /dev/null @@ -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' diff --git a/luci-app-tailscale/tests/tailscale_rpcd_test.sh b/luci-app-tailscale/tests/tailscale_rpcd_test.sh deleted file mode 100755 index 23464f2bc..000000000 --- a/luci-app-tailscale/tests/tailscale_rpcd_test.sh +++ /dev/null @@ -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" diff --git a/luci-app-tailscale/tests/tailscale_secrets_test.sh b/luci-app-tailscale/tests/tailscale_secrets_test.sh deleted file mode 100755 index dcedb1348..000000000 --- a/luci-app-tailscale/tests/tailscale_secrets_test.sh +++ /dev/null @@ -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'