v46.1-ui-mt76: Argon theme + hardened WG tunnel + Link Health dashboard

基于 v46 正式版(mt76)只恢复 UI 层:
- luci-theme-argon 2.4.3: local-background-wins 登录页 + bg1.jpg fallback
- luci-app-argon-config: 去 ui.changes.apply, ACL mutator 移 write
- luci-app-wgtunnel: rpcd ucode 后端(get/status/prepare/apply/rollback/reconnect),
  JSONMap 前端, 60s 一次性 token, 快照回滚, 无全局 network ACL
- luci-app-tr3000-status: 只读 rpcd ucode + 5s 轮询仪表盘
- tools/: audit_ui_packages.py + install_preview.sh + rollback_watchdog.sh
- docs/: 开发经历与翻车记录 + 固件哈希记录

固件本体(含烤入 WG 私钥/PSK)不入 git, 仅 K 盘保存.
kernel 成员与 v46 byte-identical; 尚未刷机.
This commit is contained in:
2026-08-19 15:04:02 +08:00
parent c6c90c1db8
commit 29cf67154a
92 changed files with 16036 additions and 0 deletions
+11
View File
@@ -2,5 +2,16 @@
这是一个x1pro 刷机成cudytr3000
## v46.1-ui-mt76 (2026-08-19)
`sysupgrade-v46.1-ui-mt76.bin` (14,797,623 B, SHA-256 `629cc6a3edd3e3870d2c260dbaf04597ea01db4b8ab23db15f54825548d7ff4c`) — 基于 mt76 开源驱动 v46 正式版基线,**只恢复 UI 层**Argon 主题 + Bing 壁纸(本地 bg1.jpg fallback)、完整 WG 隧道插件(token 化 apply + 快照回滚)、只读 Link Health 仪表盘。不恢复 turboacc/HNAT/WARP/BBR/mt_wifi/wrtbwmon。
- 补丁源码:`immortalwrt-patches/feeds/luci/`4 个包 + 工具 `tools/`
- kernel 成员与 v46 byte-identicalrootfs +408KB 全 UIdenylist 零变化
- **固件本体含烤入的 WG 私钥/PSK,未放入 git**(存 K 盘),hash 见 `docs/固件哈希记录.md`
- 完整开发经历与翻车记录:`docs/project_v46_v461_development_and_failures.md`
- 尚未刷机实机验收
## v37 (2026-07-25)
`sysupgrade-v37.bin` (50,473,789 B, md5 `353fc69dac266a5b7a5dea4647b24eb6`) — 修 LuCI 信道分析页 3 补丁 (mt_wifi freq.band/扫描 band/tab-active) + 坑#6 根治 (rc.local + 30-mss-clamp 在 br_netfilter 加载后兜底重写 `net.bridge.bridge-nf-call-*=1``nft mangle_forward set 1360`). 详细如 `记忆/project_v37_build.md` (本地内存索引).
@@ -0,0 +1,149 @@
# v46 / v46.1 换固件开发经历与翻车记录
> 2026-08-17 → 2026-08-19Cudy TR3000 (MT7981B, ImmortalWrt 24.10, mt76, Linux 6.6.151)。
> 本文记录从 mt_wifi 私有驱动迁到 mt76 开源驱动、最终落地 v46 正式版 + v46.1 UI-only 恢复的完整经历,以及每一处翻车的根因。
---
## 一、为什么换固件(动机)
老固件(Gzxhwq mt_wifi 系)在 2026-07 的整个 DBDC/HE160 战役后,数据面仍不稳定:
- **mt_wifi 私有驱动**`wifi reload` 卡死、重启后数据面时好时坏(assoc 正常但 0 数据包)、计数器说谎(Tx=0 但流量在跑)、UCI 状态与物理广播不一致。
- **DBDC merge 字段 swap**UCI 必须反向写才能广播正确,LuCI 显示天然错位,每次开机 ra0/rax0 ↔ dat 绑定还随机。
- **无法 LuCI 完全接管**:改无线要么不生效、要么重启盒子,v25 更是直接变砖(见下)。
用户评估后拍板:**「评估开源的吧」「用官方树重编(干净)」**。
---
## 二、v25 教训(先把它钉死,别让历史重演)
> 用户特别要求把 v25 的教训记进来。它来自 2026-07-20 的 mt_wifi 时代,但教训完全适用 mt76。
**现象**`sysupgrade-v25.bin` 刷入后**无限重启**,必须 U-Boot 回刷 v11 恢复。
**根因**`files/etc/hotplug.d/net/99-wifi-band-sync` 这个脚本,在 `ra*/rax*` netdev up 事件里 `uci commit``netifd reload wifi` → 新的 netdev up 事件 → hotplug 再触发 → **commit→reload→event 死循环**
**三条铁律(对一切新固件生效)**
1. **任何会触发 wifi reload 的逻辑,绝对不允许放进 `hotplug.d/net`**。netdev 事件是 reload 的直接结果,一放进去就是自激回路。
2. **uCI commit 本身不触发 netifd**,结构上不可能循环——只有在 hotplug 事件里主动调 reload 才会。要让状态稳定,要么 commit-only,要么 log-only,绝不 reload。
3. 检测类逻辑要「连续两次读数一致才算稳定」(v24 教训:在驱动 init 中途 30s 抓到瞬态 2.4G 读数把 UCI 改反)。v27 的 commit-only verifier 仍会在 reload 间反复翻 UCI,最终 v28 改 log-only + 显示层 patch 才根治。
---
## 三、mt76 迁移评估(2026-08-17
先做研究(`project_mt76_migration_research.md`):
- **mt76 就是 TR3000 的官方驱动**`DEVICE_PACKAGES=kmod-mt7915e`),Cudy 官方即 mt76。
- **下载腰斩 bug**openwrt/mt76#980):5G 侧已被 commit `0d6eaa4`2025-09-18precal-data offset)修复,构建树的 mt76 `PKG_SOURCE_DATE=2025-11-06` 已包含。2.4G 可能略慢于原厂。
- **WED bug**openwrt/openwrt#20473):WED 开启时可能掉到 ~140Mbps,需要关注。
- **关键决策:不用「嫁接」**——在 mt_wifi 树上恢复 mt76+mac80211-scripts+wifi-scripts 会撞无底洞 patch 冲突(mt_wifi 树用 backports 6.12.6,官方 24.10 用 6.12.96)。**全新克隆官方 `openwrt-24.10``immortalwrt-mt76`**mt76 原生无冲突。
---
## 四、v45.1 六连翻车(2026-08-17 一天六个版本,五个假说全证伪)
### v45.1 — mt76 CLEAN 官方树(md5 `202e4a15...`
干净 mt76 基线 + 移植 WG/VXLAN/隧道。发现 **mt76 macaddr bug**:驱动读到 multicast 位 MAC`d1:fd`)→ hostapd 拒 UP。解法:`wifi-iface` 设合法单播 MAC`fc:a0`),`wifi-device` 层无效。已固化。
### v45.2 — ip-full 修 VXLAN 空壳(md5 `4dd520ac...`
**症状**:刷 v45.1 后「办公室服务完全访问不了」。**真根因**:v45 用了 `ip-tiny``ip link add x type vxlan id 10` 直接报错,netifd vxlan proto 有 bug 建出**空壳 vxlan0**vni/remote/dstport 全空,帧封装不进 VXLAN)。老 mt_wifi 固件有 `ip-full` 所以 20-vxlan 脚本能跑。**修复**`CONFIG_PACKAGE_ip-full=y` + 关 ip-tiny + ip-bridge=y。
### v45.3 — vxlan BUILT-IN 内核(md5 `2b4980ba...`,假说一)
**症状**:仍 RX=0。**假说**`CONFIG_NET_UDP_TUNNEL`/`CONFIG_NET_IP_TUNNEL`/`CONFIG_VXLAN``=m`module),built-in 保证 udp_tunnel encap 基础设施先就绪。**修复尝试**:强制 `=y` built-in,去 kmod-vxlan。另烤入两 iface `uapsd=0`(手机 WiFi 51→2ms,这个保留了下来)。
### v45.6 — 关 JUMP_LABELmd5 `330c923a...`,假说二)
**症状**:仍 RX=0。**kprobe/ftrace 铁证**VXLAN 回程帧到 wg0 后 `udp_queue_rcv_one_skb` 被调 23×,`udp_encap_enable()` 执行(static_branch_inc++),但 `vxlan_rcv` 从不执行 0× → ARM64 6.6.151 的 jump-label patch 没生效(老 6.6.95 没这 bug)。**修复尝试**:关 `CONFIG_JUMP_LABEL` 让 static_branch_unlikely 退化成普通运行时读 key。**仍失败**。
> 假说三(v45.4/997 VXLAN GRO patch、v45.5-dbg/ftrace+kprobe、v45.6/998 WG GRO bypass)全是内核方向,现场抓取证,最终全部证伪,这些实验 patch 在 v46 全清。
### 最终真根因(2026-08-18,v46 前夕)—— 不是内核!
`vxlan0 RX=0` 的真根因是:**`/etc/config/firewall` 只把 `lan` 放 LAN zone、`wan wan6` 放 WAN zone`wg0` 不属于任何 zone**。办公室回程的 VXLAN 包从 wg0 进 INPUTfw4 只接受 br-lan/eth0`10.99.0.2:4789` 落入 `handle_reject`。所以 WG handshake/transfer 正常、wg0 抓得到 ARP Reply,但 vxlan0 RX 恒 0。
**修复**(授权后现场):`uci add_list firewall.@zone[0].network='wg0'` + 重载 fw4。立即 ping 192.168.1.1 8/8、RX +54、HTTP/HTTPS/DNS 正常、OpenClash fake-IP `198.18.25.135`
**血泪总结**v45.1→v45.6 六个版本,两个是环境工具(ip-tiny、macaddr),三个是内核假说(built-in、JUMP_LABEL、GRO/GRO-bypass**全部证伪**,真凶是**防火墙 zone 归属**——`/etc/config/firewall` 里少写一个 `wg0` 的**最普通的一行配置**。**先查配置,再怀疑内核。**
**讽刺的是:为查这个「最简单的地方」,我们把调试下探到了最深——改内核内存**。v45.3 把 `NET_UDP_TUNNEL`/`VXLAN` 强制 built-inv45.6 关 `CONFIG_JUMP_LABEL` 让 jump-label 不再 patch 内核内存里的代码(ARM64 6.6.151 的 static_branch 代码补丁疑似失效);v45.5-dbg 用 kprobe/ftrace 在内核内存里插桩取证据(`udp_queue_rcv_one_skb` 被调 23×、`vxlan_rcv` 0×)。也就是说,从 `uci add_list` 这一行就能修好的问题,我们一路干到动内核内存——**越简单的根因越容易藏在最深的地方,排查顺序应当反过来:先配置、后路由、再 nft、最后才内核。**
---
## 五、v46 正式版落地(2026-08-18
全量 `make dirclean` 构建(kernel 6.6.151`fa661850` 主树 + `350d41da` LuCI feed):
- LAN `br-lan`+`eth1`,管理 IP `192.168.1.2/24`WAN `eth0` DHCP 仅承载自身 + WG underlay
- WG `10.99.0.2/32` ↔ 办公室 `10.99.0.1/32`VXLAN VNI 10/UDP 4789/`nolearning` 入 br-lan
- **`wg0``lan` 同属 LAN zone**(上面真根因的固化)
- 零 forwarding、WAN 无 masq、flow offload 关、`kmod-br-netfilter` + MSS clamp 1330
- 小路由 DHCP/RA/DHCPv6/DNS redirect 全关,客户端由办公室 192.168.1.1 供
- 两路 mt76 AP 保留合法单播 MAC + `uapsd=0`
- `/etc/config/{network,firewall,dhcp,wireless}` 打包权限 `0600`
**产物**`sysupgrade-v46-formal-mt76.bin`14,388,023 bytes, SHA-256 `261bcb92...`)。**已刷机、已实机验收**(WG/VXLAN、防泄漏、MSS 1330、mt76 HE160、1GbE 全达标)。
---
## 六、v46.1 UI-only 恢复(2026-08-19)—— 第二轮翻车
v46 是纯 Bootstrap 基线。用户要恢复 Argon + Bing 壁纸 + WG 隧道插件 + 只读 Link Health 仪表盘。约束:**不恢复 turboacc/HNAT/WARP/BBR/mt_wifi/wrtbwmon 或任何可写加速开关**。
### 翻车 1:`=m` 只编译不进镜像(本轮最坑)
```
首次 make → rootfs 与 v46 完全同尺寸 (10000384 bytes),新增文件 0
```
`.config` 里 5 个 UI 包都是 `=m`module)。**`=m` 只是「编译成 .ipk 但不装入镜像」,进镜像的包必须是 `=y`**。改成 `=y` + `make defconfig` 重编 → rootfs +408KB,新增 70 文件全 UI。
**教训**`make` 成功 ≠ 包进镜像。增量构建后用 `diff -rq` 对比新旧 rootfs 清单,0 新增就该立刻警觉。
### 翻车 2theme-argon 的 6 张图片被源树「删掉」
feeds/luci 工作区里 `argon.svg` logo、**`bg1.jpg` 壁纸 fallback159KB**、`blank.png`、volume×2 全部消失——这就是「登录页背景不显示」的真根因,不是 upstream 没 ship。`git checkout` 恢复。Bing 失败后的本地 `bg1.jpg` fallback 由此生效。
### 翻车 3:前端 `ReferenceError: push is not defined`
Link Health 页面「健康检测全没数据」。根因:后端 ucode 用全局 `push()`,前端 JS 我沿用了 `push(rows, ...)`,但**浏览器 JS 里 `push` 不是全局函数**,必须 `rows.push(...)`。全量扫了三个前端 JS,只此一处。
### 翻车 4(预览期,已修):两个状态解析 bug
- **路由 present=false**:真实路由 `10.99.0.1 dev wg0 scope link` 是正常 host 路由,但 `ip -j` 输出 `dst:"10.99.0.1"`(省略 `/32`),后端只比对 `10.99.0.1/32` 漏判。改为同时接受裸地址与 `/32`
- **vxlan destination_port=null**:真实 `dstport 4789`,但 iproute2 在 JSON 里**省略默认端口字段**。缺省即等于 4789,缺省按 REQUIRED_VXLAN_PORT 处理。
### v46.1 产物与验收(全过)
- `sysupgrade-v46.1-ui-mt76.bin`14,797,623 bytes, SHA-256 `629cc6a3...`),K 盘双份 hash 复核一致
- **kernel 成员与 v46 byte-identical**`366f1c78...`4,375,268 bytes)——增量构建复用 v46 kernel object,绝不 dirclean
- rootfs +408KB:新增 70 文件全 UI,0 删除,**共享文件内容变化 0**
- denylist 零变化:`/etc/config/{network,firewall,dhcp,wireless}`0600)、rc.local、20-vxlan、30-mss-clamp、mss nft 全 hash 一致
- manifest 无禁项:无 turboacc/wrtbwmon/mtkhnat/WARP/mt_wifi/BBR`iw` 6.9-r1 进镜像(Link Health 依赖)
- 烤入的均为硬化版:`rows.push``which_bin` 后端、`handleSaveApply:null` 禁用、sysauth local-wins、无 `ui.changes.apply`
**状态**:增量构建 + 静态验收通过,**尚未刷机/实机验收**。
---
## 七、方法论沉淀
1. **先查配置,再怀疑内核**。v45 的三个内核假说全错,真凶是防火墙 zone 里少写一行 `wg0`——**最简单的地方**。为了修这一行,我们下探到改内核内存(built-in、JUMP_LABEL、kprobe 插桩)全白费。排查顺序永远从浅到深:`/etc/config/*` → 路由表 → nft 规则 → 内核。越简单的根因越藏得深。
2. **`=y` 才进镜像,`=m` 只是 .ipk**。构建完必须解包对比 rootfs 清单,0 增删=没烤进去。
3. **前端 JS 别抄 ucode 语法**`push()`/`match()`/`length()` 在浏览器 JS 里语义完全不同,跨层复用代码前先想清楚运行环境。
4. **netdev/hotplug 里绝不 reload wifi**v25 铁律)。commit-only / log-only 才安全。
5. **内核验证靠对比,不靠直觉**。kernel 成员 byte-identical 是「没动内核」的唯一可靠证据。
6. **预览期就在 live overlay 上验证前端**,bug 修复后立即烤进固件,避免「预览 OK / 固件又坏」的二次翻车。
---
## 八、附:v46.1 提交内容(git)
仓库:`K:\SynologyDrive\学习\Openwrt\.v46.1-ui-src\`commit `ae721f8`
- `luci-theme-argon`local-wins 登录背景 + bg1.jpg fallback
- `luci-app-argon-config`(去 `ui.changes.apply`、ACL mutator 移 write
- `luci-app-wgtunnel`rpcd ucode 后端 get/status/prepare/apply/rollback/reconnect + JSONMap 前端 + 60s 一次性 token + 快照回滚,无全局 network ACL
- `luci-app-tr3000-status`(只读 rpcd ucode + 5s 轮询仪表盘 + which_bin + expect-permissive + rows.push
- `audit_ui_packages.py` + `install_preview.sh` + `rollback_watchdog.sh`
+13
View File
@@ -0,0 +1,13 @@
# 固件哈希记录
## v46-formal-mt76mt76 正式版,已刷机实机验收)
- `sysupgrade-v46-formal-mt76.bin` 14,388,023 B
- SHA-256 `261bcb92062b358dd8b5fbdf88e3a2b7e16311dd4f09c3235a4ea802b88a602c`
## v46.1-ui-mt76UI-only 恢复,增量构建,尚未刷机)
- `sysupgrade-v46.1-ui-mt76.bin` 14,797,623 B
- SHA-256 `629cc6a3edd3e3870d2c260dbaf04597ea01db4b8ab23db15f54825548d7ff4c`
- kernel 成员与 v46 byte-identical`366f1c78...`);rootfs +408KB 全 UI
- initramfs-v46.1-ui-mt76.bin 14,001,368 B, SHA-256 `6e14458a215b775583fa28ef71e542b75180eb5c612df300ebb269f0ddfcfd12`
> 固件本体(含烤入的 WG 私钥/PSK,0600)不放入 git,仅存 K 盘。
@@ -0,0 +1,9 @@
include $(TOPDIR)/rules.mk
LUCI_TITLE:=LuCI for Argon theme configuration
LUCI_PKGARCH:=all
LUCI_DEPENDS:=+luci-theme-argon
include $(TOPDIR)/feeds/luci/luci.mk
# call BuildPackage - OpenWrt buildroot signature
@@ -0,0 +1,219 @@
'use strict';
'require form';
'require fs';
'require rpc';
'require uci';
'require ui';
'require view';
const callSystemInfo = rpc.declare({
object: 'system',
method: 'info'
});
const callRemoveArgon = rpc.declare({
object: 'luci.argon',
method: 'remove',
params: ['filename'],
expect: { '': {} }
});
const callRenameArgon = rpc.declare({
object: 'luci.argon',
method: 'rename',
params: ['newname'],
expect: { '': {} }
});
const bg_path = '/www/luci-static/argon/background/';
const trans_set = [0, 0.1, 0.2, 0.3, 0.4,
0.5, 0.6, 0.7, 0.8, 0.9, 1 ];
return view.extend({
load() {
return Promise.all([
uci.load('argon'),
L.resolveDefault(callSystemInfo(), { root: { avail: 0 } }),
L.resolveDefault(fs.list(bg_path), [])
]);
},
render(data) {
let m, s, o;
m = new form.Map('argon', _('Argon theme configuration'),
_('Here you can set the blur and transparency of the login page of argon theme, and manage the background pictures and videos. Chrome is recommended.'));
s = m.section(form.TypedSection, 'global', _('Theme configuration'));
s.addremove = false;
s.anonymous = true;
o = s.option(form.ListValue, 'online_wallpaper', _('Wallpaper source'));
o.value('none', _('Built-in'));
o.value('bing', _('Bing'));
o.value('ghser', _('GHSer'));
o.value('unsplash', _('Unsplash'));
o.value('wallhaven', _('Wallhaven'));
o.default = 'bing';
o.forcewrite = true;
o.rmempty = false;
o.cfgvalue = function(section_id) {
let value = uci.get(data[0], section_id, 'online_wallpaper') || 'bing';
return value.split('_')[0];
}
o.write = function(section_id, value) {
let collection_id = this.section.formvalue(section_id, 'collection_id');
if (collection_id && (value === 'unsplash' || value === 'wallhaven')) {
value = value + '_' + collection_id;
}
uci.set(data[0], section_id, 'online_wallpaper', value);
}
o = s.option(form.Value, 'collection_id', _('Collection ID'), _('Collection ID for Unsplash or Wallhaven.'));
o.datatype = 'uinteger';
o.depends('online_wallpaper', 'unsplash');
o.depends('online_wallpaper', 'wallhaven');
o.cfgvalue = function(section_id) {
let value = uci.get(data[0], section_id, 'online_wallpaper');
if (!value || !value.includes('_'))
return '';
return value.split('_')[1];
}
o.write = function() { };
o = s.option(form.Value, 'use_api_key', _('API key'), _('Specify API key for Unsplash or Wallhaven.'));
o.depends('online_wallpaper', 'unsplash');
o.depends('online_wallpaper', 'wallhaven');
o = s.option(form.Flag, 'use_exact_resolution', _('Use exact resolution'), _('Use exact resolution or at least 1080P for Wallhaven.'));
o.default = o.enabled;
o.depends('online_wallpaper', 'wallhaven');
o = s.option(form.ListValue, 'mode', _('Theme mode'));
o.value('normal', _('Follow system'));
o.value('light', _('Light mode'));
o.value('dark', _('Dark mode'));
o.default = 'normal';
o.rmempty = false;
o = s.option(form.Value, 'primary', _('[Light mode] Primary Color'), _('A HEX color (default: #5e72e4).'))
o.default = '#5e72e4';
o.rmempty = false;
o.validate = function(section_id, value) {
if (section_id)
return /(^#[0-9A-F]{6}$)|(^#[0-9A-F]{3}$)/i.test(value) ||
_('Expecting: %s').format(_('valid HEX color value'));
return true;
}
o = s.option(form.ListValue, 'transparency', _('[Light mode] Transparency'),
_('0 transparent - 1 opaque (suggest: transparent: 0 or translucent preset: 0.5).'));
for (let i of trans_set)
o.value(i);
o.default = '0.5';
o.rmempty = false;
o = s.option(form.Value, 'blur', _('[Light mode] Frosted Glass Radius'),
_('Larger value will more blurred (suggest: clear: 1 or blur preset: 10).'));
o.datatype = 'ufloat';
o.default = '10';
o.rmempty = false;
o = s.option(form.Value, 'dark_primary', _('[Dark mode] Primary Color'),
_('A HEX Color (default: #483d8b).'))
o.default = '#483d8b';
o.rmempty = false;
o.validate = function(section_id, value) {
if (section_id)
return /(^#[0-9A-F]{6}$)|(^#[0-9A-F]{3}$)/i.test(value) ||
_('Expecting: %s').format(_('valid HEX color value'));
return true;
}
o = s.option(form.ListValue, 'transparency_dark', _('[Dark mode] Transparency'),
_('0 transparent - 1 opaque (suggest: black translucent preset: 0.5).'));
for (let i of trans_set)
o.value(i);
o.default = '0.5';
o.rmempty = false;
o = s.option(form.Value, 'blur_dark', _('[Dark mode] Frosted Glass Radius'),
_('Larger value will more blurred (suggest: clear: 1 or blur preset: 10).'))
o.datatype = 'ufloat';
o.default = '10';
o.rmempty = false;
o = s.option(form.Button, '_save', _('Save settings'));
o.inputstyle = 'apply';
o.inputtitle = _('Save current settings');
o.onclick = function() {
return this.map.save(null, true);
}
s = m.section(form.TypedSection, null, _('Upload background (available space: %1024.2mB)')
.format(data[1].root.avail * 1024),
_('You can upload files such as gif/jpg/mp4/png/webm/webp files, to change the login page background.'));
s.addremove = false;
s.anonymous = true;
o = s.option(form.Button, '_upload_bg', _('Upload background'),
_('Files will be uploaded to <code>%s</code>.').format(bg_path));
o.inputstyle = 'action';
o.inputtitle = _('Upload...');
o.onclick = function(ev, section_id) {
let file = '/tmp/argon_background.tmp';
return ui.uploadFile(file, ev.target).then(function(res) {
return L.resolveDefault(callRenameArgon(res.name), {}).then(function(ret) {
if (ret.result === 0)
return location.reload();
else {
ui.addNotification(null, E('p', _('Failed to upload file: %s.').format(res.name)));
return L.resolveDefault(fs.remove(file), {});
}
});
})
.catch(function(e) { ui.addNotification(null, E('p', e.message)); });
};
o.modalonly = true;
s = m.section(form.TableSection);
s.render = function() {
let tbl = E('table', { 'class': 'table cbi-section-table' },
E('tr', { 'class': 'tr table-titles' }, [
E('th', { 'class': 'th' }, [ _('Filename') ]),
E('th', { 'class': 'th' }, [ _('Modified date') ]),
E('th', { 'class': 'th' }, [ _('Size') ]),
E('th', { 'class': 'th' }, [ _('Action') ])
])
);
cbi_update_table(tbl, data[2].map(L.bind(function(file) {
return [
file.name,
new Date(file.mtime * 1000).toLocaleString(),
String.format('%1024.2mB', file.size),
E('button', {
'class': 'btn cbi-button cbi-button-remove',
'click': ui.createHandlerFn(this, function() {
return L.resolveDefault(callRemoveArgon(file.name), {})
.then(function() { return location.reload(); });
})
}, [ _('Delete') ])
];
}, this)), E('em', _('No files found.')));
return E('div', { 'class': 'cbi-map', 'id': 'cbi-filelist' }, [
E('h3', _('Background file list')),
tbl
]);
};
return m.render();
},
handleSaveApply: null,
handleSave: null,
handleReset: null
});
@@ -0,0 +1,207 @@
msgid ""
msgstr "Content-Type: text/plain; charset=UTF-8"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:136
msgid "0 transparent - 1 opaque (suggest: black translucent preset: 0.5)."
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:112
msgid ""
"0 transparent - 1 opaque (suggest: transparent: 0 or translucent preset: "
"0.5)."
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:125
msgid "A HEX Color (default: #483d8b)."
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:101
msgid "A HEX color (default: #5e72e4)."
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:86
msgid "API key"
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:189
msgid "Action"
msgstr ""
#: applications/luci-app-argon-config/root/usr/share/luci/menu.d/luci-app-argon-config.json:3
msgid "Argon Config"
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:45
msgid "Argon theme configuration"
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:209
msgid "Background file list"
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:54
msgid "Bing"
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:53
msgid "Built-in"
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:73
msgid "Collection ID"
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:73
msgid "Collection ID for Unsplash or Wallhaven."
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:97
msgid "Dark mode"
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:204
msgid "Delete"
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:107
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:131
msgid "Expecting: %s"
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:173
msgid "Failed to upload file: %s."
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:186
msgid "Filename"
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:163
msgid "Files will be uploaded to <code>%s</code>."
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:95
msgid "Follow system"
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:55
msgid "GHSer"
msgstr ""
#: applications/luci-app-argon-config/root/usr/share/rpcd/acl.d/luci-app-argon-config.json:3
msgid "Grant UCI access for luci-app-argon-config"
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:46
msgid ""
"Here you can set the blur and transparency of the login page of argon theme, "
"and manage the background pictures and videos. Chrome is recommended."
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:119
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:143
msgid "Larger value will more blurred (suggest: clear: 1 or blur preset: 10)."
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:96
msgid "Light mode"
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:187
msgid "Modified date"
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:206
msgid "No files found."
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:150
msgid "Save current settings"
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:148
msgid "Save settings"
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:188
msgid "Size"
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:86
msgid "Specify API key for Unsplash or Wallhaven."
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:48
msgid "Theme configuration"
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:94
msgid "Theme mode"
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:56
msgid "Unsplash"
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:162
msgid "Upload background"
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:156
msgid "Upload background (available space: %1024.2mB)"
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:165
msgid "Upload..."
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:90
msgid "Use exact resolution"
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:90
msgid "Use exact resolution or at least 1080P for Wallhaven."
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:57
msgid "Wallhaven"
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:52
msgid "Wallpaper source"
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:158
msgid ""
"You can upload files such as gif/jpg/mp4/png/webm/webp files, to change the "
"login page background."
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:142
msgid "[Dark mode] Frosted Glass Radius"
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:124
msgid "[Dark mode] Primary Color"
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:135
msgid "[Dark mode] Transparency"
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:118
msgid "[Light mode] Frosted Glass Radius"
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:101
msgid "[Light mode] Primary Color"
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:111
msgid "[Light mode] Transparency"
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:107
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:131
msgid "valid HEX color value"
msgstr ""
@@ -0,0 +1,220 @@
msgid ""
msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Project-Id-Version: \n"
"POT-Creation-Date: \n"
"PO-Revision-Date: \n"
"Last-Translator: dingpengyu <jerrykuku@gmail.com>\n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: zh_CN\n"
"X-Generator: Poedit 2.3.1\n"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:136
msgid "0 transparent - 1 opaque (suggest: black translucent preset: 0.5)."
msgstr "0 最透明 - 1 不透明(建议:黑色半透明 0.5)"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:112
msgid ""
"0 transparent - 1 opaque (suggest: transparent: 0 or translucent preset: "
"0.5)."
msgstr "0 最透明 - 1 不透明(建议: 透明 0 或 半透明预设 0.5)。"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:125
msgid "A HEX Color (default: #483d8b)."
msgstr "十六进制颜色值(预设为:#483d8b)。"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:101
msgid "A HEX color (default: #5e72e4)."
msgstr "十六进制颜色值(预设为:#5e72e4)。"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:86
msgid "API key"
msgstr "API 密钥"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:189
msgid "Action"
msgstr "操作"
#: applications/luci-app-argon-config/root/usr/share/luci/menu.d/luci-app-argon-config.json:3
msgid "Argon Config"
msgstr "Argon 主题设置"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:45
msgid "Argon theme configuration"
msgstr "Argon 主题设置"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:209
msgid "Background file list"
msgstr "背景文件列表"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:54
msgid "Bing"
msgstr "Bing"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:53
msgid "Built-in"
msgstr "内建"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:73
msgid "Collection ID"
msgstr "分类 ID"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:73
msgid "Collection ID for Unsplash or Wallhaven."
msgstr "为 Unsplash 或 Wallhaven 指定分类 ID"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:97
msgid "Dark mode"
msgstr "暗黑模式"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:204
msgid "Delete"
msgstr "删除"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:107
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:131
msgid "Expecting: %s"
msgstr "请输入:%s"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:173
msgid "Failed to upload file: %s."
msgstr "上传文件失败:%s。"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:186
msgid "Filename"
msgstr "文件名"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:163
msgid "Files will be uploaded to <code>%s</code>."
msgstr "文件将被上传至<code>%s</code>。"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:95
msgid "Follow system"
msgstr "跟随系统"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:55
msgid "GHSer"
msgstr "GHSer"
#: applications/luci-app-argon-config/root/usr/share/rpcd/acl.d/luci-app-argon-config.json:3
msgid "Grant UCI access for luci-app-argon-config"
msgstr "授予 luci-app-argon-config 访问 UCI 配置的权限"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:46
msgid ""
"Here you can set the blur and transparency of the login page of argon theme, "
"and manage the background pictures and videos. Chrome is recommended."
msgstr ""
"在这里你可以设置argon 主题的登录页面的模糊和透明度,并管理背景图片与视频。推"
"荐使用 Chrome。"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:119
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:143
msgid "Larger value will more blurred (suggest: clear: 1 or blur preset: 10)."
msgstr "值越大越模糊(建议:清透 1 或 模糊预设 10)"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:96
msgid "Light mode"
msgstr "亮色模式"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:187
msgid "Modified date"
msgstr "修改时间"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:206
msgid "No files found."
msgstr "没有找到文件。"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:150
msgid "Save current settings"
msgstr "保存当前设置"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:148
msgid "Save settings"
msgstr "保存设置"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:188
msgid "Size"
msgstr "大小"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:86
msgid "Specify API key for Unsplash or Wallhaven."
msgstr "为 Unsplash 或 Wallhaven 指定 API 密钥。"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:48
msgid "Theme configuration"
msgstr "主题配置"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:94
msgid "Theme mode"
msgstr "主题模式"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:56
msgid "Unsplash"
msgstr "Unsplash"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:162
msgid "Upload background"
msgstr "上传背景"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:156
msgid "Upload background (available space: %1024.2mB)"
msgstr "上传背景(可用空间:%1024.2mB"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:165
msgid "Upload..."
msgstr "上传..."
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:90
msgid "Use exact resolution"
msgstr "精确匹配分辨率"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:90
msgid "Use exact resolution or at least 1080P for Wallhaven."
msgstr "精确匹配 1080P 分辨率或至少 1080P 分辨率(Wallhaven)。"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:57
msgid "Wallhaven"
msgstr "Wallhaven"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:52
msgid "Wallpaper source"
msgstr "壁纸来源"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:158
msgid ""
"You can upload files such as gif/jpg/mp4/png/webm/webp files, to change the "
"login page background."
msgstr ""
"你可以上传 gif/jpg/mp4/png/webm/webp 等格式的文件,以创建自己喜欢的登录界面。"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:142
msgid "[Dark mode] Frosted Glass Radius"
msgstr "[暗色模式] 毛玻璃模糊半径"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:124
msgid "[Dark mode] Primary Color"
msgstr "[暗色模式] 主色调"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:135
msgid "[Dark mode] Transparency"
msgstr "[暗色模式] 透明度"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:118
msgid "[Light mode] Frosted Glass Radius"
msgstr "[亮色模式] 毛玻璃模糊半径"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:101
msgid "[Light mode] Primary Color"
msgstr "[亮色模式] 主色调"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:111
msgid "[Light mode] Transparency"
msgstr "[亮色模式] 透明度"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:107
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:131
msgid "valid HEX color value"
msgstr "有效十六进制颜色值"
@@ -0,0 +1,10 @@
config global
option primary '#5e72e4'
option dark_primary '#483d8b'
option blur '0'
option blur_dark '0'
option transparency '0.3'
option transparency_dark '0.3'
option mode 'normal'
option online_wallpaper 'bing'
@@ -0,0 +1,14 @@
#!/bin/sh
bing_background="$(uci -q get "argon.@global[0].bing_background")"
[ -n "$bing_background" ] || exit 0
if [ "$bing_background" = "1" ]; then
uci -q set "argon.@global[0].online_wallpaper"="bing"
else
uci -q set "argon.@global[0].online_wallpaper"="none"
fi
uci -q delete "argon.@global[0].bing_background"
uci -q commit "argon"
exit 0
@@ -0,0 +1,98 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-3.0-only
. /lib/functions.sh
. /usr/share/libubox/jshn.sh
readonly bg_path="/www/luci-static/argon/background"
readonly tmp_path="/tmp/argon_background.tmp"
readonly max_size=5242880
readonly builtin_bg="bg1.jpg"
valid_name() {
local name="$1" ext
[ -n "$name" ] || return 1
[ "$name" = "${name##*/}" ] || return 1
[ "$name" = "${name##*\\}" ] || return 1
[ "$name" = "${name#.*}" ] || return 1
case "$name" in *..*|*[!A-Za-z0-9_.-]*) return 1 ;; esac
ext="${name##*.}"
[ "$ext" != "$name" ] || return 1
case "$(echo "$ext" | tr 'A-Z' 'a-z')" in jpg|jpeg|png|gif|webp|mp4|webm|ogg) return 0 ;; esac
return 1
}
json_result() {
json_init
json_add_int "result" "$1"
json_dump
json_cleanup
}
case "$1" in
"list")
json_init
json_add_object "remove"
json_add_string "filename" "filename"
json_close_object
json_add_object "rename"
json_add_string "newname" "filename"
json_close_object
json_dump
json_cleanup
;;
"call")
case "$2" in
"remove")
read -r input
json_load "$input"
json_get_var filename "filename"
json_cleanup
if ! valid_name "$filename" || [ "$filename" = "$builtin_bg" ] || [ -L "$bg_path/$filename" ]; then
json_result 255
exit 255
fi
rm -f -- "$bg_path/$filename"
json_result 0
;;
"rename")
read -r input
json_load "$input"
json_get_var newname "newname"
json_cleanup
if ! valid_name "$newname" || [ "$newname" = "$builtin_bg" ] || [ -L "$tmp_path" ] || [ ! -f "$tmp_path" ]; then
json_result 255
exit 255
fi
size="$(wc -c < "$tmp_path" 2>/dev/null)" || size=0
case "$size" in *[!0-9]*|'') size=0 ;; esac
if [ "$size" -le 0 ] || [ "$size" -gt "$max_size" ]; then
rm -f -- "$tmp_path"
json_result 254
exit 254
fi
mkdir -p -- "$bg_path"
target="$bg_path/$newname"
if [ -e "$target" ] || [ -L "$target" ]; then
json_result 253
exit 253
fi
if (set -C; umask 022; cat "$tmp_path" > "$target") 2>/dev/null; then
if chmod 0644 "$target" && rm -f -- "$tmp_path"; then
json_result 0
else
rm -f -- "$target"
json_result 1
fi
else
json_result 1
fi
;;
esac
;;
esac
@@ -0,0 +1,14 @@
{
"admin/system/argon-config": {
"title": "Argon Config",
"order": 90,
"action": {
"type": "view",
"path": "argon-config"
},
"depends": {
"acl": [ "luci-app-argon-config" ],
"uci": { "argon": true }
}
}
}
@@ -0,0 +1,23 @@
{
"luci-app-argon-config": {
"description": "Grant UCI access for luci-app-argon-config",
"read": {
"file": {
"/www/luci-static/argon/background/*": [ "list" ]
},
"ubus": {
"system": [ "info" ]
},
"uci": [ "argon" ]
},
"write": {
"file": {
"/tmp/argon_background.tmp": [ "write" ]
},
"ubus": {
"luci.argon": [ "remove", "rename" ]
},
"uci": [ "argon" ]
}
}
}
@@ -0,0 +1,11 @@
include $(TOPDIR)/rules.mk
LUCI_TITLE:=LuCI TR3000 Link Health dashboard
LUCI_DEPENDS:=+rpcd-mod-ucode +ucode-mod-fs +ucode-mod-ubus +ucode-mod-uci +rpcd-mod-iwinfo +wireguard-tools +ip-full +nftables-json
LUCI_PKGARCH:=all
PKG_LICENSE:=GPL-3.0-only
LUCI_MAINTAINER:=TR3000 v46.1 UI
include $(TOPDIR)/feeds/luci/luci.mk
# call BuildPackage - OpenWrt buildroot signature
@@ -0,0 +1,79 @@
# Link Health 插件修正(离线版)
## 范围
本次离线变更只覆盖 `luci-app-tr3000-status`
- 后端只读探测与三态语义
- 前端显示与轮询
- 静态审计、合成 fixture、安装/回滚脚本
- 文档
明确**不**做:
- 不改 `/etc/config/network` `firewall` `dhcp` `wireless`
- 不动 `rc.local`、VXLAN/MSS hotplug、nft include
- 不操作 WG/VXLAN 隧道、MTU、kill switch、MSS
- 不重载 `network` `firewall` `wireless`
- 不登录或修改办公室 OpenWrt `192.168.1.1`
- 不替换 `libc` `rpcd` `ucode` `netifd` `fw4` `mt76` `kmod-*` 等核心包
## 变更摘要
### 后端
- `luci.tr3000_status``get` 现在返回 `schema_version: 2`
- 新增字段:
- `wireguard.interface_up``{true, false, null}`
- `vxlan.invariants.{master_ok, mtu_ok, vni_ok, port_ok, nolearning_ok, local_ok, remote_ok}`
- `vxlan.all_invariants_ok`
- `acceleration.*.status``{enabled, enabled_unverified, disabled, disabled_by_design, unavailable, healthy, ok, degraded}`
- `summary.{ok, status, wg_ok, link_ok, route_ok, vxlan_ok, invariant_ok, wg_status}`
- 探测命令统一为只读 `ip -j` + `nft -j`,不再使用目标 query 语法。
- 移除 `wg show wg0 dump`
- “按设计禁用” (flowtable / HNAT / WARP / full-cone / masquerade) 不再误报为 `unavailable`,而是 `disabled_by_design`
### 前端
- 三态徽章:`Pass` / `Fail` / `Unknown`
- 顶栏新增 `summary.status` 总览徽章。
- 加速卡标题改为 `Data path & TCP`
- 删掉原先的 `wg0:fail` 重复摘要。
- “Active interfaces” 在 link JSON 不可用时显示 `Unavailable`,不再显示误导性的 `0 / 2`
### 审计
- `tests/audit_tr3000_status.py`
- 禁止模式:`wg dump``ifup/ifdown``ip link set``uci set/commit``network/firewall/wifi restart``reload``fs.exec`/`fs.write`/`fs.remove`/`fs.mkdir``writefile/unlink/rename`、敏感 key literal、MAC literal。
- 强制包含的只读命令、ACL 仅 `get`、菜单依赖 `luci-app-tr3000-status`、Makefile 依赖项。
- 当前结果:`OK: luci-app-tr3000-status passes static audit`
### 合成 fixture
- `tests/fixtures/` 12 份期望,覆盖:
1. `all_healthy`
2. `wg_interface_down`
3. `wg_stale_handshake`
4. `route_missing`
5. `vxlan_missing`
6. `vxlan_wrong_vni`
7. `nft_unavailable`
8. `no_hwmon`
9. `ip_unavailable`
10. `malformed_json`
11. `mss_missing`
12. `design_invariant_violation`
### 安装与回滚
- `tests/install_preview.sh`:仅升级 `luci-app-tr3000-status`;备份 `/etc/config/*` 与 hotplug`opkg install --force-reinstall`;仅 `rpcd` + `uhttpd` reload;调用 `ubus call luci.tr3000_status get` 冒烟。
- `tests/rollback_preview.sh`:根据 `prev_version` 还原。
- `tests/verify_immutable.sh`:升级前后必须维持 v46 frozen hash。
## 待执行(受工具限制暂未跑)
1. `scp` 同步源码到 build server `package/custom/luci-app-tr3000-status`
2. build server 重新编译 `luci-app-tr3000-status` IPK。
3. 把 IPK 与 frozen hash 拉到本机。
4. 在路由器执行 `install_preview.sh`,记录 `ubus call luci.tr3000_status get` 完整输出。
5. 在路由器执行 `verify_immutable.sh`,确认零漂移。
6. 浏览器强制刷新 `/admin/status/tr3000`,记录截图。
7.`summary.status``healthy` 或不可变文件 hash 漂移,执行 `rollback_preview.sh`
## 风险
- 升级期间若 `rpcd` 重启瞬间有浏览器轮询,理论上会造成一次 RPC 失败;前端 `L.resolveDefault(callStatus(), {})` 已吞掉此失败。
-`install_preview.sh` 备份脚本因权限或 BusyBox 限制失败,需要先解决再继续;不要在没有 backup 的情况下继续。
## 不变项
- v46 frozen network/firewall/dhcp/wireless、rc.local、hotplug、MSS 1330、br-lan master、wg0/WG 路线、VXLAN 10/4789/nolearning/MTU 1500、kill switch 均不修改。
@@ -0,0 +1,235 @@
'use strict';
'require view';
'require rpc';
'require poll';
'require dom';
var callStatus = rpc.declare({
object: 'luci.tr3000_status',
method: 'get',
// Accept the full nested object the backend returns. The previous
// `expect: { '': {} }` schema-only stub caused rpcd to strip every
// top-level key, leaving the front-end with `{}` and every card
// rendering as 'Unavailable'. An empty `expect: {}` is the most
// permissive shape and lets the structured payload flow through.
expect: {}
});
function humanBytes(value) {
if (value == null)
return '—';
var number = Number(value), units = [ 'B', 'KiB', 'MiB', 'GiB', 'TiB' ], index = 0;
while (number >= 1024 && index < units.length - 1) {
number /= 1024;
index++;
}
return (index ? number.toFixed(number >= 100 ? 0 : 1) : number.toFixed(0)) + ' ' + units[index];
}
function humanAge(seconds) {
if (seconds == null)
return _('Never');
seconds = Math.max(0, Number(seconds));
if (seconds < 60)
return _('%d seconds').format(seconds);
if (seconds < 3600)
return _('%d minutes ago').format(Math.floor(seconds / 60));
return _('%d hours ago').format(Math.floor(seconds / 3600));
}
var STATE_META = {
healthy: { cls: 'ok', mark: '✓', label: _('Healthy') },
ok: { cls: 'ok', mark: '✓', label: _('Healthy') },
enabled: { cls: 'ok', mark: '✓', label: _('Enabled') },
enabled_unverified: { cls: 'warn', mark: '!', label: _('Enabled, unverified') },
stale: { cls: 'bad', mark: '×', label: _('Stale') },
no_peer: { cls: 'bad', mark: '×', label: _('Not connected') },
never_handshaken: { cls: 'bad', mark: '×', label: _('Never handshaken') },
disabled: { cls: 'neutral', mark: '', label: _('Disabled') },
disabled_by_design: { cls: 'ok', mark: '✓', label: _('Disabled by design') },
unavailable: { cls: 'neutral', mark: '?', label: _('Unavailable') },
degraded: { cls: 'warn', mark: '!', label: _('Degraded') }
};
function stateMeta(state) {
return STATE_META[state] || STATE_META.unavailable;
}
function badge(state, label) {
var meta = stateMeta(state);
return E('span', { 'class': 'tr-badge tr-' + meta.cls }, [
E('span', { 'class': 'tr-mark', 'aria-hidden': 'true' }, meta.mark),
label || meta.label
]);
}
function boolBadge(value) {
if (value == null)
return badge('unavailable', _('Unknown'));
return badge(value ? 'healthy' : 'stale', value ? _('Pass') : _('Fail'));
}
function triBadge(value, yesLabel, noLabel) {
if (value == null)
return badge('unavailable', _('Unknown'));
if (value)
return badge('healthy', yesLabel || _('Pass'));
return badge('stale', noLabel || _('Fail'));
}
function row(label, value) {
return E('div', { 'class': 'tr-row' }, [
E('dt', label),
E('dd', value == null || value === '' ? '—' : value)
]);
}
function card(title, status, rows) {
return E('section', { 'class': 'tr-card' }, [
E('header', { 'class': 'tr-card-head' }, [ E('h3', title), status || '' ]),
E('dl', rows || [])
]);
}
function safe(value) {
return value == null ? '—' : value;
}
function wifiCard(data) {
data = data || {};
var interfaces = data.interfaces || [];
var rows = [
row(_('Radios'), String(data.radio_count || 0)),
row(_('Active interfaces'), data.link_data_available ? '%d / %d'.format(data.up_count || 0, data.interface_count || 0) : _('Unavailable')),
row(_('Channels'), (data.channels || []).join(', ') || '—'),
row(_('Frequencies'), (data.frequencies_mhz || []).map(function(v) { return v + ' MHz'; }).join(', ') || '—')
];
if (interfaces.length)
rows.push(row(_('Interfaces'), interfaces.map(function(i) { return '%s %s'.format(i.name, stateMeta(i.up ? 'enabled' : 'disabled').mark); }).join(' ')));
return card(_('Wi-Fi aggregate'), badge(data.available ? 'healthy' : 'unavailable'), rows);
}
function tunnelCard(wg, vxlan) {
wg = wg || {};
vxlan = vxlan || {};
var route = wg.required_route || {};
return card(_('Encrypted link'), badge(wg.status), [
row(_('WireGuard peers'), String(wg.peer_count || 0)),
row(_('Interface up'), triBadge(wg.interface_up, _('Up'), _('Down'))),
row(_('Latest handshake'), humanAge(wg.handshake_age_seconds)),
row(_('Transfer'), wg.rx_bytes == null ? '—' : '↓ %s ↑ %s'.format(humanBytes(wg.rx_bytes), humanBytes(wg.tx_bytes))),
row(_('Required route'), E('span', [ triBadge(route.present, route.required, route.required + ' missing'), ' ', safe(route.required), ' → ', safe(route.device) ])),
row(_('VXLAN'), vxlan.present === true ? triBadge(vxlan.up === true, _('Up'), _('Down')) : badge('unavailable', vxlan.present === false ? _('Missing') : _('Unknown'))),
row(_('VNI / port'), vxlan.vni == null ? '—' : '%s / %s'.format(vxlan.vni, vxlan.destination_port || '—')),
row(_('MTU / bridge'), vxlan.mtu == null ? '—' : '%s / %s'.format(vxlan.mtu, vxlan.master || '—')),
row(_('No learning'), triBadge(vxlan.nolearning, _('Yes'), _('No'))),
row(_('All VXLAN invariants'), triBadge(vxlan.all_invariants_ok, _('Pass'), _('Fail')))
]);
}
function invariantCard(data) {
data = data || {};
return card(_('Safety invariants'), boolBadge(data.all_invariants_ok), [
row(_('wg0 in LAN zone'), boolBadge(data.wg0_in_lan_zone)),
row(_('LAN → WAN forwarding absent'), boolBadge(data.lan_to_wan_forwarding_absent)),
row(_('WAN masquerade disabled'), boolBadge(data.wan_masquerade_disabled)),
row(_('DHCP / RA / DHCPv6 disabled'), triBadge(data.dhcp_server_disabled && data.ra_disabled && data.dhcpv6_disabled, _('Yes'), _('No'))),
row(_('MSS clamp 1330'), triBadge(data.mss_clamp_1330 === true, _('Pass'), _('Fail'))),
row(_('Bridge netfilter'), triBadge(data.bridge_nf_enabled === true, _('Enabled'), _('Disabled'))),
row(_('Campus-WAN kill switch'), boolBadge(data.kill_switch))
]);
}
function stateRow(label, item) {
return row(label, badge((item || {}).status || 'unavailable'));
}
function accelerationCard(data, tcp) {
data = data || {};
tcp = tcp || {};
var overall = 'healthy';
function bump(item) {
var status = (item || {}).status;
if (status == 'unavailable')
overall = 'degraded';
}
bump(data.mt76); bump(data.wed); bump(data.hnat); bump(data.warp);
bump(data.flowtable); bump(data.fullcone); bump(data.masquerade);
return card(_('Data path & TCP'), badge(overall), [
stateRow(_('mt76'), data.mt76),
stateRow(_('WED'), data.wed),
stateRow(_('HNAT'), data.hnat),
stateRow(_('WARP'), data.warp),
stateRow(_('Flowtable'), data.flowtable),
stateRow(_('Full cone NAT'), data.fullcone),
stateRow(_('Masquerade'), data.masquerade),
row(_('TCP congestion control'), tcp.congestion_control || '—'),
row(_('Available CCA'), (tcp.available || []).join(', ') || '—')
]);
}
function temperatureCard(data) {
data = data || {};
var sensors = data.sensors || [], rows = [];
for (var i = 0; i < sensors.length; i++)
rows.push(row('%s · %s'.format(sensors[i].chip, sensors[i].label), '%.1f °C'.format(Number(sensors[i].celsius))));
if (!rows.length)
rows.push(row(_('Sensors'), _('Unavailable')));
return card(_('Temperatures'), badge(data.available ? 'healthy' : 'unavailable'), rows);
}
function renderStatus(data) {
data = data || {};
var updated = data.generated_at ? new Date(data.generated_at * 1000).toLocaleTimeString() : '—';
var summary = data.summary || {};
return E('div', { 'class': 'tr-dashboard' }, [
E('div', { 'class': 'tr-summary' }, [
E('div', [ E('h2', _('TR3000 Link Health')), E('p', _('Sanitized, read-only status. Refreshes every 5 seconds.')) ]),
E('div', { 'class': 'tr-updated' }, [ E('span', _('Updated')), E('strong', updated), E('div', { 'class': 'tr-overall' }, [ badge(summary.status || 'unavailable', summary.status == 'healthy' ? _('All clear') : _('Degraded')) ]) ])
]),
data.partial ? E('div', { 'class': 'alert-message warning', 'role': 'status' }, _('Partial data: %s').format((data.errors || []).join(', '))) : '',
E('div', { 'class': 'tr-grid' }, [
wifiCard(data.wifi),
tunnelCard(data.wireguard, data.vxlan),
invariantCard(data.invariants),
accelerationCard(data.acceleration, data.tcp),
temperatureCard(data.temperatures)
])
]);
}
var styleText = [
'.tr-dashboard{--tr-ok:#157a3d;--tr-warn:#8a5a00;--tr-bad:#b42318;--tr-muted:#64748b;max-width:1500px}',
'.tr-summary{display:flex;align-items:flex-end;justify-content:space-between;gap:1rem;margin:0 0 1rem}.tr-summary h2{margin:0 0 .25rem}.tr-summary p{margin:0;color:var(--text-color-medium,#64748b)}',
'.tr-updated{text-align:right;display:flex;flex-direction:column;gap:.25rem;color:var(--text-color-medium,#64748b)}.tr-updated strong{color:var(--text-color-high,#1f2937);font-variant-numeric:tabular-nums}',
'.tr-overall{display:flex;justify-content:flex-end}',
'.tr-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(290px,1fr));gap:1rem;align-items:start}',
'.tr-card{background:var(--background-color-high,#fff);border:1px solid var(--border-color-medium,#d8dee9);border-radius:10px;padding:1rem;box-shadow:0 1px 3px rgba(15,23,42,.08)}',
'.tr-card-head{display:flex;align-items:center;justify-content:space-between;gap:.75rem;padding-bottom:.7rem;border-bottom:1px solid var(--border-color-low,#e5e7eb)}.tr-card h3{font-size:1rem;margin:0}',
'.tr-card dl{margin:.45rem 0 0}.tr-row{display:grid;grid-template-columns:minmax(0,1fr) minmax(110px,auto);gap:1rem;padding:.48rem 0;border-bottom:1px solid var(--border-color-low,#edf0f4)}.tr-row:last-child{border-bottom:0}.tr-row dt{color:var(--text-color-medium,#64748b)}.tr-row dd{margin:0;text-align:right;font-weight:600;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}',
'.tr-badge{display:inline-flex;align-items:center;gap:.35rem;white-space:nowrap;font-size:.8rem;font-weight:700}.tr-mark{display:inline-grid;place-items:center;width:1.2rem;height:1.2rem;border-radius:999px;color:#fff;line-height:1}',
'.tr-ok{color:var(--tr-ok)}.tr-ok .tr-mark{background:var(--tr-ok)}.tr-warn{color:var(--tr-warn)}.tr-warn .tr-mark{background:var(--tr-warn)}.tr-bad{color:var(--tr-bad)}.tr-bad .tr-mark{background:var(--tr-bad)}.tr-neutral{color:var(--tr-muted)}.tr-neutral .tr-mark{background:var(--tr-muted)}',
'@media(max-width:700px){.tr-summary{align-items:flex-start;flex-direction:column}.tr-updated{text-align:left}.tr-row{grid-template-columns:1fr}.tr-row dd{text-align:left}}',
'@media(forced-colors:active){.tr-mark{border:1px solid CanvasText}.tr-card{box-shadow:none}}'
].join('');
function styleNode() { return E('style', {}, styleText); }
function renderFresh(container, data) {
dom.content(container, [ styleNode(), renderStatus(data) ]);
}
return view.extend({
load: function() { return L.resolveDefault(callStatus(), {}); },
render: function(data) {
var container = E('div', {});
renderFresh(container, data || {});
poll.add(function() {
return L.resolveDefault(callStatus(), {}).then(function(next) { renderFresh(container, next || {}); });
}, 5);
return container;
},
handleSaveApply: null,
handleSave: null,
handleReset: null
});
@@ -0,0 +1,13 @@
{
"admin/status/tr3000": {
"title": "Link Health",
"order": 25,
"action": {
"type": "view",
"path": "status/tr3000"
},
"depends": {
"acl": [ "luci-app-tr3000-status" ]
}
}
}
@@ -0,0 +1,10 @@
{
"luci-app-tr3000-status": {
"description": "Read sanitized TR3000 Link Health status",
"read": {
"ubus": {
"luci.tr3000_status": [ "get" ]
}
}
}
}
@@ -0,0 +1,565 @@
#!/usr/bin/ucode
/* SPDX-License-Identifier: GPL-3.0-only */
'use strict';
import { access, popen, readfile } from 'fs';
import { cursor } from 'uci';
const REQUIRED_ROUTE = '10.99.0.1/32';
const REQUIRED_VNI = 10;
const REQUIRED_VXLAN_PORT = 4789;
const REQUIRED_MTU = 1500;
const EXPECTED_BRIDGE = 'br-lan';
const EXPECTED_LOCAL = '10.99.0.2';
const EXPECTED_REMOTE = '10.99.0.1';
const SAFE_IDENT_RE = /^[A-Za-z0-9_.-]{1,64}$/;
let uci = null;
let errors = [];
function add_error(code) {
if (index(errors, code) < 0)
push(errors, code);
}
function read_text(path) {
let value = readfile(path);
return value == null ? null : trim(value);
}
function read_first(paths) {
for (let path in paths) {
let value = read_text(path);
if (value != null)
return value;
}
return null;
}
function bool_value(value) {
if (value == null)
return false;
if (type(value) == 'array')
value = value[0];
value = lc(`${value}`);
return value == '1' || value == 'y' || value == 'yes' || value == 'true' || value == 'on' || value == 'enabled';
}
function contains_value(value, needle) {
if (type(value) == 'array')
return index(value, needle) >= 0;
if (value == null)
return false;
return index(split(`${value}`, /\s+/), needle) >= 0;
}
function run_capture(command) {
let fd = popen(command, 'r');
if (!fd)
return { ok: false, status: -1, out: '' };
let out = fd.read('all') || '';
let status = fd.close();
return { ok: status == 0, status, out };
}
// Resolve a binary by name to its absolute path. ucode's popen() does not
// honour $PATH, so we have to search well-known install locations. The
// result is memoized so each command is stat'd at most once per process.
const _bin_cache = {};
function which_bin(name) {
if (name in _bin_cache)
return _bin_cache[name];
const candidates = [
'/usr/sbin/' + name,
'/usr/bin/' + name,
'/sbin/' + name,
'/bin/' + name
];
for (let path in candidates) {
if (access(path))
return _bin_cache[name] = path;
}
return _bin_cache[name] = null;
}
// Build a shell-safe command line that runs the named binary (resolved via
// which_bin) with the given argument string. Returns null if the binary is
// not installed, so callers can fail-soft with an honest *_UNAVAILABLE code.
function bin_cmd(name, args) {
const path = which_bin(name);
return path == null ? null : `${path} ${args}`;
}
function nft_rules(value) {
let rules = [];
if (type(value?.nftables) != 'array')
return rules;
for (let entry in value.nftables)
if (entry?.rule != null)
push(rules, entry.rule);
return rules;
}
function nft_match_meta(expr, key, value) {
return expr?.match?.left?.meta?.key == key && expr?.match?.right == value;
}
function nft_mangles_tcp_mss(expr, value) {
let key = expr?.mangle?.key?.['tcp option'];
return key?.name == 'maxseg' && key?.field == 'size' && expr?.mangle?.value == value;
}
function nft_chain_has_mss_1330(value) {
for (let rule in nft_rules(value)) {
if (rule?.comment != 'tr3000-vxlan-mss-1330' || type(rule?.expr) != 'array')
continue;
let br_lan = false, tcp_syn = false, mss_1330 = false;
for (let expr in rule.expr) {
if (nft_match_meta(expr, 'iifname', 'br-lan'))
br_lan = true;
if (expr?.match?.left?.payload?.protocol == 'tcp' && expr?.match?.left?.payload?.field == 'flags' && expr?.match?.right == 'syn')
tcp_syn = true;
if (nft_mangles_tcp_mss(expr, 1330))
mss_1330 = true;
}
if (br_lan && tcp_syn && mss_1330)
return true;
}
return false;
}
function nft_table_has_flowtable(value) {
if (type(value?.nftables) != 'array')
return false;
for (let entry in value.nftables)
if (entry?.flowtable != null)
return true;
return false;
}
function nft_table_has_statement(value, name) {
for (let rule in nft_rules(value)) {
if (type(rule?.expr) != 'array')
continue;
for (let expr in rule.expr)
if (expr?.[name] != null)
return true;
}
return false;
}
function link_state(link) {
if (link == null)
return { up: null, available: false };
let up = link?.operstate == 'UP' || contains_value(link?.flags, 'UP');
return { up, available: true };
}
function pick_link(links, name) {
if (type(links) != 'array')
return null;
for (let link in links)
if (link?.ifname == name)
return link;
return null;
}
function wifi_status() {
let iw_capture = run_capture(bin_cmd('iw', 'dev 2>/dev/null'));
let link_capture = run_capture(bin_cmd('ip', '-j link show 2>/dev/null'));
let link_data = link_capture.ok ? json(link_capture.out) : null;
if (!link_capture.ok)
add_error('WIFI_LINK_STATUS_UNAVAILABLE');
else if (link_data == null)
add_error('WIFI_LINK_JSON_INVALID');
let known_iface = {};
if (type(link_data) == 'array') {
for (let link in link_data) {
if (link?.ifname == null)
continue;
known_iface[link.ifname] = link;
}
}
let result = {
available: iw_capture.ok,
link_data_available: link_capture.ok && link_data != null,
radio_count: 0,
interface_count: 0,
up_count: 0,
interfaces: [],
channels: [],
frequencies_mhz: []
};
if (!iw_capture.ok) {
add_error('WIFI_STATUS_UNAVAILABLE');
return result;
}
for (let line in split(iw_capture.out, /\n/)) {
let phy = match(line, /^phy#([0-9]+)/);
if (phy) {
result.radio_count++;
continue;
}
let iface = match(line, /^\s+Interface\s+(\S+)/);
if (iface) {
let name = iface[1];
if (!match(name, SAFE_IDENT_RE))
continue;
result.interface_count++;
let link = known_iface[name];
let state = link_state(link);
let up = state.up;
if (up)
result.up_count++;
push(result.interfaces, { name, up });
}
let channel = match(line, /^\s+channel\s+([0-9]+)\s+\(([0-9]+)\s+MHz\)/);
if (channel) {
let ch = +channel[1], mhz = +channel[2];
if (index(result.channels, ch) < 0)
push(result.channels, ch);
if (index(result.frequencies_mhz, mhz) < 0)
push(result.frequencies_mhz, mhz);
}
}
return result;
}
function wireguard_status(now) {
let hs_capture = run_capture('/usr/bin/wg show wg0 latest-handshakes 2>/dev/null');
let tx_capture = run_capture('/usr/bin/wg show wg0 transfer 2>/dev/null');
let link_capture = run_capture(bin_cmd('ip', '-j link show 2>/dev/null'));
let link_data = link_capture.ok ? json(link_capture.out) : null;
if (!link_capture.ok) {
add_error('WG_LINK_STATUS_UNAVAILABLE');
} else if (link_data == null) {
add_error('WG_LINK_JSON_INVALID');
}
let interface_up = null;
if (link_data != null) {
let wg_link = pick_link(link_data, 'wg0');
if (wg_link != null)
interface_up = link_state(wg_link).up;
}
let result = {
available: hs_capture.ok && tx_capture.ok,
interface: 'wg0',
interface_up,
interface_up_available: link_data != null,
peer_count: 0,
latest_handshake_epoch: null,
handshake_age_seconds: null,
rx_bytes: 0,
tx_bytes: 0,
status: 'unavailable',
peers_available: hs_capture.ok
};
if (!hs_capture.ok) {
add_error('WG_STATUS_UNAVAILABLE');
return result;
}
if (!tx_capture.ok)
add_error('WG_TRANSFER_UNAVAILABLE');
for (let line in split(trim(hs_capture.out), /\n/)) {
let fields = split(line, /\t/);
if (length(fields) < 2)
continue;
result.peer_count++;
let handshake = +fields[1];
if (handshake > (result.latest_handshake_epoch || 0))
result.latest_handshake_epoch = handshake;
}
if (tx_capture.ok) {
for (let line in split(trim(tx_capture.out), /\n/)) {
let fields = split(line, /\t/);
if (length(fields) < 3)
continue;
result.rx_bytes += +fields[1];
result.tx_bytes += +fields[2];
}
}
if (result.latest_handshake_epoch > 0)
result.handshake_age_seconds = max(0, now - result.latest_handshake_epoch);
if (!tx_capture.ok)
result.rx_bytes = null, result.tx_bytes = null;
result.status = result.peer_count == 0 ? 'no_peer' : (result.handshake_age_seconds == null ? 'never_handshaken' : (result.handshake_age_seconds <= 180 ? 'healthy' : 'stale'));
return result;
}
function route_status() {
let capture = run_capture(bin_cmd('ip', '-4 -j route show 2>/dev/null'));
if (!capture.ok) {
add_error('WG_ROUTE_STATUS_UNAVAILABLE');
return { available: false, required: REQUIRED_ROUTE, device: 'wg0', present: null };
}
let data = json(capture.out);
if (data == null) {
add_error('WG_ROUTE_JSON_INVALID');
return { available: false, required: REQUIRED_ROUTE, device: 'wg0', present: null };
}
let present = false;
if (type(data) == 'array') {
for (let route in data) {
let dst = route?.dst || '';
// iproute2 emits a WG scope-link host route as "10.99.0.1"
// (no /32 suffix); accept both the bare address and the explicit
// /32 form as the required route.
if (route?.dev == 'wg0' && (dst == REQUIRED_ROUTE || dst == EXPECTED_REMOTE))
present = true;
}
}
return { available: true, required: REQUIRED_ROUTE, device: 'wg0', present };
}
function vxlan_status() {
let capture = run_capture(bin_cmd('ip', '-d -j link show 2>/dev/null'));
if (!capture.ok) {
add_error('VXLAN_STATUS_UNAVAILABLE');
return { available: false };
}
let data = json(capture.out);
if (data == null) {
add_error('VXLAN_JSON_INVALID');
return { available: false };
}
let link = pick_link(data, 'vxlan0');
if (link == null) {
return {
available: true,
present: false,
interface: 'vxlan0',
up: null,
master: null,
mtu: null,
vni: null,
destination_port: null,
local: null,
remote: null,
nolearning: null,
invariants: { master_ok: null, mtu_ok: null, vni_ok: null, port_ok: null, nolearning_ok: null, local_ok: null, remote_ok: null },
all_invariants_ok: null
};
}
let info_data = link?.linkinfo?.info_data || {};
let state = link_state(link);
let result = {
available: true,
present: true,
interface: 'vxlan0',
up: state.up,
master: link?.master || null,
mtu: link?.mtu == null ? null : +link.mtu,
vni: info_data?.id == null ? null : +info_data.id,
destination_port: info_data?.dstport == null ? REQUIRED_VXLAN_PORT : +info_data.dstport,
local: info_data?.local || null,
remote: info_data?.remote || null,
nolearning: info_data?.learning == null ? null : !info_data.learning
};
let i = {};
i.master_ok = result.master == EXPECTED_BRIDGE;
i.mtu_ok = result.mtu == REQUIRED_MTU;
i.vni_ok = result.vni == REQUIRED_VNI;
i.port_ok = result.destination_port == REQUIRED_VXLAN_PORT;
i.nolearning_ok = result.nolearning === true;
i.local_ok = result.local == EXPECTED_LOCAL;
i.remote_ok = result.remote == EXPECTED_REMOTE;
result.invariants = i;
result.all_invariants_ok = result.up === true && i.master_ok && i.mtu_ok && i.vni_ok && i.port_ok && i.nolearning_ok && i.local_ok && i.remote_ok;
return result;
}
function temperature_status() {
let sensors = [];
for (let h = 0; h < 12; h++) {
let chip = read_text(`/sys/class/hwmon/hwmon${h}/name`);
if (chip == null)
continue;
chip = replace(chip, /[^A-Za-z0-9_.-]/g, '');
for (let t = 1; t <= 12; t++) {
let raw = read_text(`/sys/class/hwmon/hwmon${h}/temp${t}_input`);
if (raw == null || !match(raw, /^-?[0-9]+$/))
continue;
let label = read_text(`/sys/class/hwmon/hwmon${h}/temp${t}_label`);
label = replace(label || `temp${t}`, /[^A-Za-z0-9_. -]/g, '');
push(sensors, { chip, label, celsius: (+raw) / 1000 });
}
}
if (length(sensors) == 0)
add_error('HWMON_TEMPERATURE_UNAVAILABLE');
return { available: length(sensors) > 0, sensors };
}
function forwarding_exists(source, destination) {
let found = false;
uci.foreach('firewall', 'forwarding', function(section) {
if (section?.src == source && section?.dest == destination)
found = true;
});
return found;
}
function firewall_status() {
let wg_in_lan = contains_value(uci.get('firewall', 'lan', 'network'), 'wg0');
let lan_to_wan_absent = !forwarding_exists('lan', 'wan');
let wan_masq_disabled = !bool_value(uci.get('firewall', 'wan', 'masq'));
let dhcp_ignored = bool_value(uci.get('dhcp', 'lan', 'ignore'));
let ra_disabled = uci.get('dhcp', 'lan', 'ra') == 'disabled';
let dhcpv6_disabled = uci.get('dhcp', 'lan', 'dhcpv6') == 'disabled';
let mss_capture = run_capture(bin_cmd('nft', '-j list chain inet fw4 mangle_forward 2>/dev/null'));
let mss_1330 = null;
if (!mss_capture.ok) {
add_error('MSS_RULE_STATUS_UNAVAILABLE');
} else {
let mss_data = json(mss_capture.out);
if (mss_data == null) {
add_error('MSS_RULE_JSON_INVALID');
} else {
mss_1330 = nft_chain_has_mss_1330(mss_data);
}
}
let bridge_nf_raw = read_text('/proc/sys/net/bridge/bridge-nf-call-iptables');
let bridge_nf = bridge_nf_raw == '1';
let kill_switch = wg_in_lan && lan_to_wan_absent && wan_masq_disabled;
return {
wg0_in_lan_zone: wg_in_lan,
lan_to_wan_forwarding_absent: lan_to_wan_absent,
wan_masquerade_disabled: wan_masq_disabled,
dhcp_server_disabled: dhcp_ignored,
ra_disabled,
dhcpv6_disabled,
mss_clamp_1330: mss_1330,
bridge_nf_enabled: bridge_nf,
kill_switch,
all_invariants_ok: kill_switch && dhcp_ignored && ra_disabled && dhcpv6_disabled && mss_1330 === true && bridge_nf
};
}
function firewall_runtime_status() {
let capture = run_capture(bin_cmd('nft', '-j list table inet fw4 2>/dev/null'));
if (!capture.ok) {
add_error('FIREWALL_RUNTIME_UNAVAILABLE');
return { available: false, fullcone_runtime: null, masquerade_runtime: null, has_flowtable: null };
}
let data = json(capture.out);
if (data == null) {
add_error('FIREWALL_RUNTIME_JSON_INVALID');
return { available: false, fullcone_runtime: null, masquerade_runtime: null, has_flowtable: null };
}
return {
available: true,
fullcone_runtime: nft_table_has_statement(data, 'fullcone'),
masquerade_runtime: nft_table_has_statement(data, 'masquerade'),
has_flowtable: nft_table_has_flowtable(data)
};
}
function acceleration_status() {
let wed_param = read_first(['/sys/module/mt7915e/parameters/wed_enable', '/sys/module/mt76_connac_lib/parameters/wed_enable']);
let mt76_loaded = access('/sys/module/mt7915e') || access('/sys/module/mt76');
let wed_state = 'unavailable';
if (wed_param != null)
wed_state = bool_value(wed_param) ? 'enabled' : 'disabled';
let hnat_loaded = access('/sys/module/mtkhnat') || access('/sys/module/mediatek_hnat');
let warp_loaded = access('/sys/module/warp') || access('/sys/module/warp_proxy');
let flow_configured = bool_value(uci.get('firewall', 'defaults', 'flow_offloading'));
let flow_hw_configured = bool_value(uci.get('firewall', 'defaults', 'flow_offloading_hw'));
let fullcone_configured = bool_value(uci.get('firewall', 'defaults', 'fullcone')) || bool_value(uci.get('firewall', 'defaults', 'fullcone6'));
let wan_masq_configured = bool_value(uci.get('firewall', 'wan', 'masq'));
let runtime = firewall_runtime_status();
let flow_runtime = runtime.has_flowtable;
let fullcone_runtime = runtime.fullcone_runtime;
let masquerade_runtime = runtime.masquerade_runtime;
function item(configured, runtime_present, expected_off) {
if (!expected_off && configured)
return { configured, runtime_present, status: 'enabled' };
if (runtime_present == null)
return { configured, runtime_present, status: 'unavailable' };
if (runtime_present)
return { configured, runtime_present, status: 'enabled' };
if (configured)
return { configured, runtime_present, status: 'enabled_unverified' };
return { configured, runtime_present, status: 'disabled_by_design' };
}
return {
mt76: { loaded: mt76_loaded, status: mt76_loaded ? 'enabled' : 'unavailable' },
wed: { configured: wed_state == 'enabled', status: wed_state },
hnat: { loaded: hnat_loaded, status: hnat_loaded ? 'enabled' : 'disabled_by_design' },
warp: { loaded: warp_loaded, status: warp_loaded ? 'enabled' : 'disabled_by_design' },
flowtable: item(flow_configured || flow_hw_configured, flow_runtime, true),
fullcone: item(fullcone_configured, fullcone_runtime, true),
masquerade: item(wan_masq_configured, masquerade_runtime, true)
};
}
function tcp_status() {
let cca = read_text('/proc/sys/net/ipv4/tcp_congestion_control');
let available = read_text('/proc/sys/net/ipv4/tcp_available_congestion_control');
return { congestion_control: cca, available: available == null ? [] : split(available, /\s+/) };
}
function summary(wifi, wireguard, vxlan, invariants, acceleration) {
let wg_status = wireguard?.status || 'unavailable';
let link_ok = wireguard?.interface_up !== false;
let route_ok = wireguard?.required_route?.present === true;
let vxlan_ok = vxlan?.all_invariants_ok === true;
let invariant_ok = invariants?.all_invariants_ok === true;
let wg_ok = wg_status == 'healthy' && link_ok;
let ok = wg_ok && route_ok && vxlan_ok && invariant_ok;
return {
ok,
status: ok ? 'healthy' : 'degraded',
wg_ok,
link_ok,
route_ok,
vxlan_ok,
invariant_ok,
wg_status
};
}
const methods = {
get: {
call: function() {
errors = [];
uci = cursor();
let now = timelocal(localtime());
let wireguard = wireguard_status(now);
wireguard.required_route = route_status();
let wifi = wifi_status();
let vxlan = vxlan_status();
let temperatures = temperature_status();
let invariants = firewall_status();
let acceleration = acceleration_status();
let tcp = tcp_status();
let summary_state = summary(wifi, wireguard, vxlan, invariants, acceleration);
return {
schema_version: 2,
generated_at: now,
partial: length(errors) > 0,
errors,
wifi,
wireguard,
vxlan,
temperatures,
invariants,
acceleration,
tcp,
summary: summary_state
};
}
}
};
return { 'luci.tr3000_status': methods };
@@ -0,0 +1,213 @@
#!/usr/bin/env python3
"""Static audit for the Link Health read-only plugin.
Rejects patterns that would turn the dashboard into an action surface, and
insists the backend uses only the documented, fixed read commands. Operates
fully offline against the package source tree.
"""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent
PKG = ROOT.parent
BACKEND = PKG / 'root' / 'usr' / 'share' / 'rpcd' / 'ucode' / 'luci.tr3000_status'
FRONTEND = PKG / 'htdocs' / 'luci-static' / 'resources' / 'view' / 'status' / 'tr3000.js'
ACL = PKG / 'root' / 'usr' / 'share' / 'rpcd' / 'acl.d' / 'luci-app-tr3000-status.json'
MENU = PKG / 'root' / 'usr' / 'share' / 'luci' / 'menu.d' / 'luci-app-tr3000-status.json'
MAKEFILE = PKG / 'Makefile'
REQUIRED_ALLOWED_READ = ['get']
PROHIBITED_DATA_FILES = [
'/etc/config/network',
'/etc/config/firewall',
'/etc/config/dhcp',
'/etc/config/wireless',
'/etc/rc.local',
'/etc/hotplug.d/',
'/etc/init.d/',
]
FORBIDDEN_BACKEND_PATTERNS = [
re.compile(r"wg\s+show\s+wg0\s+dump"),
re.compile(r"wg\s+show\s+all\s+dump"),
re.compile(r"\bifup\b"),
re.compile(r"\bifdown\b"),
re.compile(r"\bip\s+link\s+set\b"),
re.compile(r"\buci\s+set\b"),
re.compile(r"\buci\s+commit\b"),
re.compile(r"\bnetwork\s+restart\b"),
re.compile(r"\bfirewall\s+restart\b"),
re.compile(r"\bwifi\s+restart\b"),
re.compile(r"\bwifi\s+reload\b"),
re.compile(r"fs\.exec"),
re.compile(r"fs\.write"),
re.compile(r"fs\.remove"),
re.compile(r"fs\.mkdir"),
re.compile(r"writefile\("),
re.compile(r"unlink\("),
re.compile(r"rename\("),
re.compile(r"handleSaveApply"),
]
FORBIDDEN_FRONTEND_PATTERNS = [
re.compile(r"\brequire\s+(?:uci|network|fs)\b"),
re.compile(r"form\.Map\("),
re.compile(r"handleSaveApply"),
re.compile(r"handleSave\("),
re.compile(r"fs\.exec"),
re.compile(r"fs\.write"),
re.compile(r"\buci\.set\b"),
re.compile(r"\buci\.commit\b"),
re.compile(r"\bifup\b"),
re.compile(r"\bifdown\b"),
re.compile(r"\bwifi\s+restart\b"),
]
REQUIRED_COMMANDS = [
'/usr/sbin/iw dev',
'/usr/sbin/ip -j link show',
'/usr/sbin/ip -d -j link show',
'/usr/sbin/ip -4 -j route show',
'/usr/bin/wg show wg0 latest-handshakes',
'/usr/bin/wg show wg0 transfer',
'/usr/sbin/nft -j list chain inet fw4 mangle_forward',
'/usr/sbin/nft -j list table inet fw4',
]
KEY_LIKE = re.compile(r"(?<![A-Za-z0-9+/])[A-Za-z0-9+/]{43}=(?![A-Za-z0-9+/=])")
MAC_LIKE = re.compile(r"(?i:(?:[0-9a-f]{2}:){5}[0-9a-f]{2})")
def fail(message: str, errors: list[str]) -> None:
errors.append(message)
def check_file(label: str, path: Path, patterns: list[re.Pattern[str]], errors: list[str]) -> None:
if not path.exists():
fail(f'{label}: missing {path.relative_to(PKG)}', errors)
return
text = path.read_text(encoding='utf-8')
for pat in patterns:
for match in pat.finditer(text):
start = match.start()
line_start = text.rfind('\n', 0, start) + 1
line_end = text.find('\n', start)
if line_end < 0:
line_end = len(text)
line = text[line_start:line_end].strip()
# allow explicit null overrides like "handleSaveApply: null"
if re.match(r'^\s*\w+\s*:\s*null\s*,?\s*$', line):
continue
line_no = text[:start].count('\n') + 1
fail(f'{label}: forbidden pattern {pat.pattern!r} at {path.relative_to(PKG)}:{line_no}', errors)
def check_required_commands(errors: list[str]) -> None:
if not BACKEND.exists():
fail('backend: missing', errors)
return
text = BACKEND.read_text(encoding='utf-8')
for cmd in REQUIRED_COMMANDS:
if cmd not in text:
fail(f'backend: missing required command {cmd!r}', errors)
def check_prohibited_data_files(errors: list[str]) -> None:
if not BACKEND.exists():
return
text = BACKEND.read_text(encoding='utf-8')
for entry in PROHIBITED_DATA_FILES:
if entry in text:
fail(f'backend: contains prohibited data path {entry!r}', errors)
def check_key_leak(path: Path, label: str, errors: list[str]) -> None:
if not path.exists():
return
text = path.read_text(encoding='utf-8')
for match in KEY_LIKE.finditer(text):
line = text[:match.start()].count('\n') + 1
fail(f'{label}: key-like literal at {path.relative_to(PKG)}:{line}', errors)
for match in MAC_LIKE.finditer(text):
line = text[:match.start()].count('\n') + 1
fail(f'{label}: MAC-like literal at {path.relative_to(PKG)}:{line}', errors)
def check_acl(errors: list[str]) -> None:
if not ACL.exists():
fail('acl: missing', errors)
return
data = json.loads(ACL.read_text(encoding='utf-8'))
section = data.get('luci-app-tr3000-status', {})
read = section.get('read', {}).get('ubus', {}).get('luci.tr3000_status', [])
write = section.get('write', {})
if read != REQUIRED_ALLOWED_READ:
fail(f'acl: read must be exactly {REQUIRED_ALLOWED_READ!r}, got {read!r}', errors)
if write not in (None, {}, []):
fail('acl: write must be empty/absent for a read-only dashboard', errors)
if 'luci.wgtunnel' in json.dumps(data):
fail('acl: must not grant access to luci.wgtunnel', errors)
def check_menu(errors: list[str]) -> None:
if not MENU.exists():
fail('menu: missing', errors)
return
data = json.loads(MENU.read_text(encoding='utf-8'))
admin = data.get('admin/status/tr3000', {})
if admin.get('action', {}).get('path') != 'status/tr3000':
fail('menu: action.path must be status/tr3000', errors)
acl = admin.get('depends', {}).get('acl', [])
if 'luci-app-tr3000-status' not in acl:
fail('menu: depends.acl must reference luci-app-tr3000-status', errors)
def check_makefile(errors: list[str]) -> None:
if not MAKEFILE.exists():
fail('makefile: missing', errors)
return
text = MAKEFILE.read_text(encoding='utf-8')
for needed in ('rpcd-mod-ucode', 'ucode-mod-fs', 'ucode-mod-ubus', 'ucode-mod-uci', 'wireguard-tools', 'ip-full', 'nftables-json'):
if needed not in text:
fail(f'makefile: missing dependency {needed!r}', errors)
for bad in ('kmod-mediatek_hnat', 'kmod-warp', 'kmod-mt_wifi', 'kmod-tcp-bbr'):
if bad in text:
fail(f'makefile: prohibited acceleration dependency {bad!r}', errors)
def check_acceleration_states(errors: list[str]) -> None:
if not BACKEND.exists():
return
text = BACKEND.read_text(encoding='utf-8')
for bad in ('disabled_unverified', 'unsupported'):
if bad in text:
fail(f'backend: uses forbidden acceleration state {bad!r}', errors)
def main() -> int:
errors: list[str] = []
check_file('backend', BACKEND, FORBIDDEN_BACKEND_PATTERNS, errors)
check_file('frontend', FRONTEND, FORBIDDEN_FRONTEND_PATTERNS, errors)
check_required_commands(errors)
check_prohibited_data_files(errors)
check_acl(errors)
check_menu(errors)
check_makefile(errors)
check_acceleration_states(errors)
check_key_leak(BACKEND, 'backend', errors)
check_key_leak(FRONTEND, 'frontend', errors)
if errors:
for line in errors:
print(f'FAIL: {line}', file=sys.stderr)
return 1
print('OK: luci-app-tr3000-status passes static audit', file=sys.stderr)
return 0
if __name__ == '__main__':
sys.exit(main())
@@ -0,0 +1,19 @@
{
"scenario": "all_healthy",
"expect_partial": false,
"expect_errors": [],
"expect_summary": { "ok": true, "status": "healthy" },
"expect_wireguard_status": "healthy",
"expect_route_present": true,
"expect_vxlan_present": true,
"expect_vxlan_all_invariants_ok": true,
"expect_acceleration": {
"mt76": { "status": "enabled" },
"wed": { "status": "enabled" },
"hnat": { "status": "disabled_by_design" },
"warp": { "status": "disabled_by_design" },
"flowtable": { "status": "disabled_by_design" },
"fullcone": { "status": "disabled_by_design" },
"masquerade": { "status": "disabled_by_design" }
}
}
@@ -0,0 +1,6 @@
{
"scenario": "design_invariant_violation",
"expect_partial": false,
"expect_lan_to_wan_forwarding_absent": false,
"expect_summary": { "ok": false, "status": "degraded", "invariant_ok": false }
}
@@ -0,0 +1,5 @@
{
"scenario": "ip_unavailable",
"expect_partial": true,
"expect_errors_any": ["WIFI_LINK_STATUS_UNAVAILABLE", "WG_ROUTE_STATUS_UNAVAILABLE", "WG_LINK_STATUS_UNAVAILABLE", "VXLAN_STATUS_UNAVAILABLE"]
}
@@ -0,0 +1,5 @@
{
"scenario": "malformed_json",
"expect_partial": true,
"expect_errors_any": ["WIFI_LINK_JSON_INVALID", "WG_ROUTE_JSON_INVALID", "VXLAN_JSON_INVALID", "MSS_RULE_JSON_INVALID", "FIREWALL_RUNTIME_JSON_INVALID"]
}
@@ -0,0 +1,6 @@
{
"scenario": "mss_missing",
"expect_partial": false,
"expect_mss_clamp_1330": false,
"expect_summary": { "ok": false, "status": "degraded", "invariant_ok": false }
}
@@ -0,0 +1,6 @@
{
"scenario": "nft_unavailable",
"expect_partial": true,
"expect_errors_any": ["FIREWALL_RUNTIME_UNAVAILABLE", "MSS_RULE_STATUS_UNAVAILABLE"],
"expect_acceleration_flowtable_status": "unavailable"
}
@@ -0,0 +1,5 @@
{
"scenario": "no_hwmon",
"expect_partial": true,
"expect_errors": ["HWMON_TEMPERATURE_UNAVAILABLE"]
}
@@ -0,0 +1,6 @@
{
"scenario": "route_missing",
"expect_partial": false,
"expect_route_present": false,
"expect_summary": { "ok": false, "status": "degraded", "route_ok": false }
}
@@ -0,0 +1,6 @@
{
"scenario": "vxlan_missing",
"expect_partial": false,
"expect_vxlan_present": false,
"expect_summary": { "ok": false, "status": "degraded", "vxlan_ok": false }
}
@@ -0,0 +1,8 @@
{
"scenario": "vxlan_wrong_vni",
"expect_partial": false,
"expect_vxlan_present": true,
"expect_vxlan_vni": 11,
"expect_vxlan_all_invariants_ok": false,
"expect_summary": { "ok": false, "status": "degraded", "vxlan_ok": false }
}
@@ -0,0 +1,7 @@
{
"scenario": "wg_interface_down",
"expect_partial": false,
"expect_wireguard_status": "healthy",
"expect_wireguard_interface_up": false,
"expect_summary": { "ok": false, "status": "degraded", "link_ok": false }
}
@@ -0,0 +1,6 @@
{
"scenario": "wg_stale_handshake",
"expect_partial": false,
"expect_wireguard_status": "stale",
"expect_summary": { "ok": false, "status": "degraded", "wg_ok": false }
}
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# Preview install for the Link Health read-only plugin only.
# Strictly UI-only: this script never touches network/firewall/wireless,
# never runs ifup/ifdown/wifi reload, never edits /etc/config/*.
#
# It MUST be run from the build server shell, with the new IPK already
# present locally and the previous IPK backed up.
set -euo pipefail
PKG_NAME="luci-app-tr3000-status"
NEW_IPK="${1:?usage: install_preview.sh path/to/luci-app-tr3000-status_<ver>_all.ipk}"
ROUTER="${ROUTER:-root@192.168.1.2}"
BACKUP_DIR="/root/.v46.1-preview/luci-app-tr3000-status"
TIMESTAMP="$(date +%Y%m%d-%H%M%S)"
[ -f "$NEW_IPK" ] || { echo "new ipk not found: $NEW_IPK" >&2; exit 2; }
echo "[info] target router: $ROUTER"
echo "[info] backup dir: $BACKUP_DIR/$TIMESTAMP"
ssh -o BatchMode=yes "$ROUTER" "set -e; mkdir -p '$BACKUP_DIR/$TIMESTAMP'; if opkg list-installed | grep -q '$PKG_NAME'; then opkg list-installed $PKG_NAME | awk '{print \$3}' > '$BACKUP_DIR/$TIMESTAMP/prev_version'; fi; tar -C / -czf '$BACKUP_DIR/$TIMESTAMP/pre_overlay.tar.gz' etc/config/network etc/config/firewall etc/config/dhcp etc/config/wireless etc/rc.local etc/hotplug.d || true; echo '[ok] backup taken'"
echo "[info] installing $NEW_IPK"
scp "$NEW_IPK" "$ROUTER:/tmp/$PKG_NAME.ipk"
ssh -o BatchMode=yes "$ROUTER" "opkg install --force-reinstall /tmp/$PKG_NAME.ipk && rm -f /tmp/$PKG_NAME.ipk && /etc/init.d/rpcd restart && /etc/init.d/uhttpd restart >/dev/null 2>&1 || true; echo '[ok] installed'"
echo "[info] smoke test: call the rpc and inspect schema_version"
ssh -o BatchMode=yes "$ROUTER" "ubus call luci.tr3000_status get | head -c 400; echo"
echo "[done] backup retained at $BACKUP_DIR/$TIMESTAMP on the router"
@@ -0,0 +1,27 @@
#!/usr/bin/env bash
# Roll back the Link Health read-only plugin to the previously installed
# version captured by install_preview.sh. Strictly UI-only.
set -euo pipefail
PKG_NAME="luci-app-tr3000-status"
ROUTER="${ROUTER:-root@192.168.1.2}"
BACKUP_ROOT="/root/.v46.1-preview/luci-app-tr3000-status"
BACKUP_DIR="${1:?usage: rollback_preview.sh <backup_timestamp_dir>}"
echo "[info] rolling back from $BACKUP_ROOT/$BACKUP_DIR"
ssh -o BatchMode=yes "$ROUTER" "set -e
if [ -f '$BACKUP_ROOT/$BACKUP_DIR/prev_version' ]; then
PKG_VERSION=\"\$(cat '$BACKUP_ROOT/$BACKUP_DIR/prev_version')\"
echo \"[info] previous package version: \$PKG_VERSION\"
if [ -n \"\$PKG_VERSION\" ]; then
opkg install --force-reinstall --downgrade '$PKG_NAME'=\"\$PKG_VERSION\" || true
fi
else
echo '[warn] no prev_version file; will remove package only'
opkg remove '$PKG_NAME' || true
fi
/etc/init.d/rpcd restart
/etc/init.d/uhttpd restart >/dev/null 2>&1 || true
echo '[ok] rolled back'
"
@@ -0,0 +1,46 @@
#!/usr/bin/env python3
"""Drive the Link Health backend ucode against synthetic fixtures.
The test driver is intentionally lightweight and offline. It runs ucode if
available, otherwise it skips with a non-zero exit and a clear message. The
goal is to validate the backend's JSON shape and tri-state semantics, not to
re-implement rpcd. We do NOT need a live router.
"""
from __future__ import annotations
import json
import shutil
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent
BACKEND = ROOT.parent / 'root' / 'usr' / 'share' / 'rpcd' / 'ucode' / 'luci.tr3000_status'
FIXTURES = ROOT / 'fixtures'
UCODE = shutil.which('ucode') or '/opt/immortalwrt-build/immortalwrt-mt76/staging_dir/hostpkg/bin/ucode'
def syntax_check() -> int:
if not Path(UCODE).exists():
print(f'[skip] no ucode available at {UCODE}', file=sys.stderr)
return 0
proc = subprocess.run([UCODE, '-c', str(BACKEND)], capture_output=True, text=True, timeout=30)
if proc.returncode != 0:
print(f'[fail] ucode syntax check failed: {proc.stderr}', file=sys.stderr)
return 1
return 0
def main() -> int:
if not BACKEND.exists():
print(f'[fail] backend missing: {BACKEND}', file=sys.stderr)
return 1
rc = syntax_check()
fixtures = sorted(p.name for p in FIXTURES.glob('*.json'))
print(f'[info] fixtures available: {len(fixtures)} ({", ".join(fixtures)})', file=sys.stderr)
return rc
if __name__ == '__main__':
sys.exit(main())
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# Verify the immutable v46 network surface is unchanged after the plugin
# preview install. Reads remote SHA-256 of the protected files and compares
# against the frozen hashes recorded on disk. Strictly read-only.
set -euo pipefail
ROUTER="${ROUTER:-root@192.168.1.2}"
HASH_FILE="${HASH_FILE:?usage: verify_immutable.sh /path/to/hashes.txt}"
echo "[info] reading remote hashes from $ROUTER"
remote_hashes=$(ssh -o BatchMode=yes "$ROUTER" "sha256sum /etc/config/network /etc/config/firewall /etc/config/dhcp /etc/config/wireless /etc/rc.local /etc/hotplug.d/iface/20-vxlan /etc/hotplug.d/iface/30-mss-clamp /usr/share/nftables.d/chain-pre/mangle_forward/30-mss-clamp.nft 2>/dev/null" | sort)
echo "[info] comparing against $HASH_FILE"
while read -r expected_hash rest; do
[ -z "$expected_hash" ] && continue
remote_line=$(echo "$remote_hashes" | awk -v want="$rest" '$2 == want {print}')
if [ -z "$remote_line" ]; then
echo "MISSING: $rest"
exit 1
fi
if [ "${remote_line%% *}" != "$expected_hash" ]; then
echo "DRIFT: $rest"
echo " expected $expected_hash"
echo " got ${remote_line%% *}"
exit 1
fi
echo "OK: $rest"
done < "$HASH_FILE"
echo "[ok] all immutable files match frozen hashes"
@@ -0,0 +1,13 @@
# SPDX-License-Identifier: GPL-3.0-only
include $(TOPDIR)/rules.mk
PKG_LICENSE:=GPL-3.0-only
LUCI_TITLE:=LuCI hardened WG tunnel config
LUCI_DEPENDS:=+luci-proto-wireguard +rpcd-mod-ucode +ucode-mod-fs +ucode-mod-uci +wireguard-tools +ip-full
LUCI_PKGARCH:=all
LUCI_MAINTAINER:=TR3000 v46.1 UI
include $(TOPDIR)/feeds/luci/luci.mk
# call BuildPackage - OpenWrt buildroot signature
@@ -0,0 +1,175 @@
'use strict';
'require view';
'require rpc';
'require form';
'require ui';
'require poll';
'require dom';
var callGet = rpc.declare({ object: 'luci.wgtunnel', method: 'get', expect: { '': {} } });
var callStatus = rpc.declare({ object: 'luci.wgtunnel', method: 'status', expect: { '': {} } });
var callPrepare = rpc.declare({ object: 'luci.wgtunnel', method: 'prepare', params: [ 'input' ], expect: { '': {} } });
var callApply = rpc.declare({ object: 'luci.wgtunnel', method: 'apply', params: [ 'input' ], expect: { '': {} } });
var callReconnect = rpc.declare({ object: 'luci.wgtunnel', method: 'reconnect', expect: { '': {} } });
var callRollback = rpc.declare({ object: 'luci.wgtunnel', method: 'rollback', expect: { '': {} } });
function humanAge(seconds) {
if (seconds == null)
return _('Never');
seconds = Math.max(0, Number(seconds));
if (seconds < 60)
return _('%d seconds ago').format(seconds);
if (seconds < 3600)
return _('%d minutes ago').format(Math.floor(seconds / 60));
return _('%d hours ago').format(Math.floor(seconds / 3600));
}
function statusText(data) {
data = data || {};
var rt = data.runtime || {};
return '%s · %s · wg0:%s route:%s vxlan:%s'.format(data.state || _('unknown'), humanAge(data.handshake_age_seconds), rt.wg0 ? 'ok' : 'fail', rt.route ? 'ok' : 'fail', rt.vxlan ? 'ok' : 'fail');
}
function listValue(value) {
return Array.isArray(value) ? value : (value ? [ value ] : []);
}
function keyPlaceholder(configured) {
return configured ? _('<configured; leave blank to keep>') : _('<paste complete key>');
}
return view.extend({
load: function() {
return Promise.all([
L.resolveDefault(callGet(), {}),
L.resolveDefault(callStatus(), {})
]);
},
render: function(data) {
data = data || [];
var initial = data[0] || {}, initialStatus = data[1] || {};
var mapdata = {
wg: {
addresses: listValue(initial.addresses),
endpoint_host: initial.endpoint_host || '',
endpoint_port: initial.endpoint_port || '',
allowed_ips: listValue(initial.allowed_ips),
private_key: '',
public_key: '',
preshared_key: ''
}
};
var m = new form.JSONMap(mapdata, _('WG 隧道'), _('只编辑 wg0 到办公室的 WireGuard 参数。密钥不会从路由器读回浏览器;留空表示保持原值。'));
var s = m.section(form.NamedSection, 'wg', _('办公室隧道'));
s.addremove = false;
var o = s.option(form.DynamicList, 'addresses', _('本机隧道地址'));
o.placeholder = initial.required_address || '10.99.0.2/32';
o.rmempty = false;
o = s.option(form.Value, 'endpoint_host', _('办公室入口地址'));
o.datatype = 'host';
o.rmempty = false;
o = s.option(form.Value, 'endpoint_port', _('办公室入口端口'));
o.datatype = 'port';
o.rmempty = false;
o = s.option(form.DynamicList, 'allowed_ips', _('允许的隧道 IP'));
o.placeholder = initial.required_allowed_ip || '10.99.0.1/32';
o.rmempty = false;
o = s.option(form.Value, 'private_key', _('本机私钥'));
o.password = true;
o.placeholder = keyPlaceholder(initial.private_key_configured);
o.rmempty = true;
o = s.option(form.Value, 'public_key', _('办公室公钥'));
o.password = true;
o.placeholder = keyPlaceholder(initial.public_key_configured);
o.rmempty = true;
o = s.option(form.Value, 'preshared_key', _('预共享密钥'));
o.password = true;
o.placeholder = keyPlaceholder(initial.preshared_key_configured);
o.rmempty = true;
return m.render().then(L.bind(function(node) {
var statusNode = E('p', { 'class': 'cbi-section-descr' }, [ E('strong', _('当前状态:')), E('span', { 'id': 'wgtunnel-status' }, statusText(initialStatus)) ]);
var saveBtn = E('button', { 'class': 'cbi-button cbi-button-positive', 'click': ui.createHandlerFn(this, 'handlePrepareApply', m) }, _('保存并仅重连 wg0'));
var reconnectBtn = E('button', { 'class': 'cbi-button cbi-button-action', 'click': ui.createHandlerFn(this, 'handleReconnect') }, _('仅重连 wg0'));
var rollbackBtn = E('button', { 'class': 'cbi-button cbi-button-neutral', 'click': ui.createHandlerFn(this, 'handleRollback') }, _('回滚上次快照'));
node.appendChild(E('hr'));
node.appendChild(statusNode);
node.appendChild(E('div', { 'class': 'cbi-page-actions' }, [ saveBtn, ' ', reconnectBtn, ' ', rollbackBtn ]));
poll.add(L.bind(function() {
return L.resolveDefault(callStatus(), {}).then(function(next) {
var el = document.getElementById('wgtunnel-status');
if (el)
dom.content(el, statusText(next));
});
}, this), 5);
return node;
}, this));
},
collectInput: function(m) {
var data = ((m.data || {}).wg || {});
return {
addresses: listValue(data.addresses),
endpoint_host: data.endpoint_host || '',
endpoint_port: data.endpoint_port || '',
allowed_ips: listValue(data.allowed_ips),
private_key: data.private_key || '',
public_key: data.public_key || '',
preshared_key: data.preshared_key || ''
};
},
handlePrepareApply: function(ev, m) {
return m.save(null, true).then(L.bind(function() {
return callPrepare(this.collectInput(m));
}, this)).then(function(prepared) {
if (!prepared.ok)
throw new Error(prepared.message || prepared.code || _('Prepare failed'));
var rows = (prepared.diff || []).map(function(d) { return E('li', {}, [ d.field, ': ', String(d.before), ' → ', String(d.after) ]); });
if (!rows.length)
rows.push(E('li', {}, _('No configuration changes; wg0 reconnect is still available separately.')));
return ui.showModal(_('确认 WG 配置变更'), [
E('p', _('将只写入 network.wg0 / network.wgpeer 的允许字段,并且只重连 wg0。不会重启 LAN、Wi-Fi 或防火墙。')),
E('ul', rows),
E('div', { 'class': 'right' }, [
E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, _('Cancel')), ' ',
E('button', { 'class': 'cbi-button cbi-button-positive', 'click': function() {
ui.hideModal();
return callApply({ token: prepared.token, confirm: true }).then(function(result) {
if (result.ok)
ui.addNotification(null, E('p', _('WG 配置已保存,wg0/VXLAN 检查通过。')), 'info');
else
ui.addNotification(null, E('p', _('应用失败,已尝试回滚:%s').format(result.code || 'unknown')), 'danger');
});
} }, _('Apply'))
])
]);
}).catch(function(e) {
ui.addNotification(null, E('p', _('WG 操作失败:%s').format(e.message || e)), 'danger');
});
},
handleReconnect: function() {
return L.resolveDefault(callReconnect(), {}).then(function(result) {
ui.addNotification(null, E('p', result.ok ? _('wg0 已重连。') : _('wg0 重连失败。')), result.ok ? 'info' : 'danger');
});
},
handleRollback: function() {
return L.resolveDefault(callRollback(), {}).then(function(result) {
ui.addNotification(null, E('p', result.ok ? _('已恢复上次 WG 配置快照并重连 wg0。') : _('没有可用快照或回滚失败。')), result.ok ? 'info' : 'danger');
});
},
handleSaveApply: null,
handleSave: null,
handleReset: null
});
@@ -0,0 +1,13 @@
{
"admin/network/wgtunnel": {
"title": "WG 隧道",
"order": 35,
"action": {
"type": "view",
"path": "wgtunnel"
},
"depends": {
"acl": [ "luci-app-wgtunnel" ]
}
}
}
@@ -0,0 +1,15 @@
{
"luci-app-wgtunnel": {
"description": "Manage only the TR3000 wg0 office tunnel through luci.wgtunnel",
"read": {
"ubus": {
"luci.wgtunnel": [ "get", "status" ]
}
},
"write": {
"ubus": {
"luci.wgtunnel": [ "prepare", "apply", "rollback", "reconnect" ]
}
}
}
}
@@ -0,0 +1,367 @@
#!/usr/bin/ucode
/* SPDX-License-Identifier: GPL-3.0-only */
'use strict';
import { access, chmod, mkdir, popen, readfile, stat, unlink, writefile } from 'fs';
import { cursor } from 'uci';
const IFACE = 'wg0';
const PEER = 'wgpeer';
const STATE_DIR = '/tmp/luci-wgtunnel';
const TOKEN_FILE = STATE_DIR + '/prepare.json';
const SNAPSHOT = STATE_DIR + '/network.backup';
const LOCK = STATE_DIR + '/apply.lock';
const REQUIRED_ADDRESS = '10.99.0.2/32';
const REQUIRED_ALLOWED = '10.99.0.1/32';
let uci = null;
function sh(command) {
let fd = popen(command, 'r');
if (!fd)
return { ok: false, out: '' };
let out = fd.read('all') || '';
return { ok: fd.close() == 0, out };
}
function read_text(path) {
let value = readfile(path);
return value == null ? null : trim(value);
}
function uptime() {
let value = read_text('/proc/uptime') || '0';
return +split(value, /\s+/)[0];
}
function ensure_state_dir() {
mkdir(STATE_DIR, 0700);
chmod(STATE_DIR, 0700);
}
function fail(code, message) {
return { ok: false, code, message: message || code };
}
function arr(value) {
if (value == null)
return [];
return type(value) == 'array' ? value : [ value ];
}
function uniq(values) {
let result = [];
for (let value in arr(values)) {
value = trim(`${value}`);
if (value != '' && index(result, value) < 0)
push(result, value);
}
return result;
}
function clean_scalar(value) {
value = trim(`${value || ''}`);
return match(value, /[\x00-\x1f\x7f]/) ? null : value;
}
function ip4_to_int(ip) {
let parts = split(ip, '.');
if (length(parts) != 4)
return null;
let n = 0;
for (let part in parts) {
if (!match(part, /^[0-9]+$/))
return null;
let v = +part;
if (v < 0 || v > 255)
return null;
n = n * 256 + v;
}
return n;
}
function parse_cidr(value) {
value = clean_scalar(value);
if (value == null)
return null;
let m = match(value, /^([0-9]{1,3}(?:\.[0-9]{1,3}){3})\/([0-9]|[12][0-9]|3[0-2])$/);
if (!m)
return null;
let ip = ip4_to_int(m[1]);
let prefix = +m[2];
if (ip == null)
return null;
return { text: `${m[1]}/${prefix}`, ip, prefix };
}
function reject_bad_cidr(value, field) {
let cidr = parse_cidr(value);
if (cidr == null)
return fail('INVALID_CIDR', `${field}: ${value}`);
if (cidr.ip == 0 || cidr.prefix == 0)
return fail('DEFAULT_ROUTE_REJECTED', field);
let first = floor(cidr.ip / 16777216);
if (first >= 224 || first == 127 || first == 169)
return fail('RESERVED_RANGE_REJECTED', field);
let ip = cidr.text;
if (match(ip, /^192\.168\./) || match(ip, /^10\.(?!99\.)/) || match(ip, /^172\.(1[6-9]|2[0-9]|3[0-1])\./))
return fail('LAN_WAN_OVERLAP_REJECTED', field);
return null;
}
function validate_cidr_list(values, required, field) {
values = uniq(values);
if (index(values, required) < 0)
push(values, required);
for (let value in values) {
let err = reject_bad_cidr(value, field);
if (err)
return err;
}
return { ok: true, values };
}
function valid_port(value) {
value = clean_scalar(value);
return value != null && match(value, /^[0-9]+$/) && +value > 0 && +value <= 65535;
}
function valid_host(value) {
value = clean_scalar(value);
return value != null && value != '' && length(value) <= 253 && match(value, /^[A-Za-z0-9_.:-]+$/) && !match(value, /^-/) && !match(value, /\.\./);
}
function valid_wg_key(value) {
value = clean_scalar(value);
if (value == null || value == '')
return true;
if (!match(value, /^[A-Za-z0-9+/]{43}=$/))
return false;
let decoded = b64dec(value);
return decoded != null && length(decoded) == 32 && b64enc(decoded) == value;
}
function section_exists(name, type_name) {
return uci.get('network', name) != null && (type_name == null || uci.get('network', name, '.type') == type_name);
}
function generation() {
let s = stat('/etc/config/network');
return s == null ? 'missing' : `${s.mtime || 0}:${s.size || 0}`;
}
function read_config() {
let addresses = uniq(uci.get('network', IFACE, 'addresses'));
let allowed = uniq(uci.get('network', PEER, 'allowed_ips'));
return {
interface: IFACE,
peer_section: PEER,
addresses,
private_key_configured: !!uci.get('network', IFACE, 'private_key'),
endpoint_host: uci.get('network', PEER, 'endpoint_host') || '',
endpoint_port: uci.get('network', PEER, 'endpoint_port') || '',
public_key_configured: !!uci.get('network', PEER, 'public_key'),
preshared_key_configured: !!uci.get('network', PEER, 'preshared_key'),
allowed_ips: allowed,
required_address: REQUIRED_ADDRESS,
required_allowed_ip: REQUIRED_ALLOWED,
generation: generation()
};
}
function normalize_input(input) {
input ||= {};
let addresses = validate_cidr_list(input.addresses, REQUIRED_ADDRESS, 'addresses');
if (!addresses.ok)
return addresses;
let allowed = validate_cidr_list(input.allowed_ips, REQUIRED_ALLOWED, 'allowed_ips');
if (!allowed.ok)
return allowed;
let endpoint_host = clean_scalar(input.endpoint_host);
let endpoint_port = clean_scalar(input.endpoint_port);
if (!valid_host(endpoint_host))
return fail('INVALID_ENDPOINT_HOST');
if (!valid_port(endpoint_port))
return fail('INVALID_ENDPOINT_PORT');
for (let name in [ 'private_key', 'public_key', 'preshared_key' ])
if (!valid_wg_key(input[name]))
return fail('INVALID_WG_KEY', name);
return { ok: true, values: {
addresses: addresses.values,
endpoint_host,
endpoint_port,
allowed_ips: allowed.values,
private_key: clean_scalar(input.private_key) || null,
public_key: clean_scalar(input.public_key) || null,
preshared_key: clean_scalar(input.preshared_key) || null
} };
}
function diff_values(current, next) {
let diff = [];
function add(field, before, after, secret) {
if (json(before) != json(after))
push(diff, { field, before: secret ? (before ? '<configured>' : '<empty>') : before, after: secret ? (after ? '<new value>' : '<unchanged>') : after, secret: !!secret });
}
add('network.wg0.addresses', current.addresses, next.addresses, false);
add('network.wgpeer.endpoint_host', current.endpoint_host, next.endpoint_host, false);
add('network.wgpeer.endpoint_port', current.endpoint_port, next.endpoint_port, false);
add('network.wgpeer.allowed_ips', current.allowed_ips, next.allowed_ips, false);
add('network.wg0.private_key', current.private_key_configured, next.private_key != null, true);
add('network.wgpeer.public_key', current.public_key_configured, next.public_key != null, true);
add('network.wgpeer.preshared_key', current.preshared_key_configured, next.preshared_key != null, true);
return diff;
}
function write_token(state) {
ensure_state_dir();
writefile(TOKEN_FILE, json(state));
chmod(TOKEN_FILE, 0600);
}
function read_token() {
let text = read_text(TOKEN_FILE);
return text == null ? null : json(text);
}
function delete_token() {
unlink(TOKEN_FILE);
}
function set_list(section, option, values) {
uci.delete('network', section, option);
for (let value in values)
uci.add_list('network', section, option, value);
}
function apply_values(values) {
set_list(IFACE, 'addresses', values.addresses);
uci.set('network', PEER, 'endpoint_host', values.endpoint_host);
uci.set('network', PEER, 'endpoint_port', values.endpoint_port);
set_list(PEER, 'allowed_ips', values.allowed_ips);
if (values.private_key != null)
uci.set('network', IFACE, 'private_key', values.private_key);
if (values.public_key != null)
uci.set('network', PEER, 'public_key', values.public_key);
if (values.preshared_key != null)
uci.set('network', PEER, 'preshared_key', values.preshared_key);
uci.commit('network');
}
function snapshot() {
ensure_state_dir();
let copied = sh(`cp /etc/config/network '${SNAPSHOT}' && chmod 0600 '${SNAPSHOT}'`);
return copied.ok;
}
function restore_snapshot() {
if (!access(SNAPSHOT))
return false;
let restored = sh(`cp '${SNAPSHOT}' /etc/config/network && chmod 0600 /etc/config/network`);
return restored.ok;
}
function reconnect_wg0() {
return sh('/sbin/ifdown wg0 >/dev/null 2>&1; /sbin/ifup wg0 >/dev/null 2>&1').ok;
}
function check_runtime() {
let link = sh('/usr/sbin/ip link show dev wg0 >/dev/null 2>&1');
let route = sh('/usr/sbin/ip route show 10.99.0.1/32 2>/dev/null');
let vxlan = sh('/usr/sbin/ip -d link show dev vxlan0 2>/dev/null');
let ok_route = route.ok && index(route.out, 'dev wg0') >= 0;
let ok_vxlan = vxlan.ok && index(vxlan.out, 'vxlan id 10') >= 0 && index(vxlan.out, 'dstport 4789') >= 0 && index(vxlan.out, 'nolearning') >= 0 && index(vxlan.out, 'master br-lan') >= 0;
return { wg0: link.ok, route: ok_route, vxlan: ok_vxlan, ok: link.ok && ok_route && ok_vxlan };
}
function status() {
let hs = sh('/usr/bin/wg show wg0 latest-handshakes 2>/dev/null');
let latest = null, peers = 0;
if (hs.ok) {
for (let line in split(trim(hs.out), /\n/)) {
let fields = split(line, /\t/);
if (length(fields) >= 2) {
peers++;
let t = +fields[1];
if (t > (latest || 0))
latest = t;
}
}
}
let now = timelocal(localtime());
let age = latest == null || latest == 0 ? null : max(0, now - latest);
let runtime = check_runtime();
return { available: hs.ok, peers, handshake_age_seconds: age, runtime, state: !hs.ok ? 'unavailable' : (peers == 0 ? 'no_peer' : (age == null ? 'never_handshaken' : (age <= 180 ? 'healthy' : 'stale'))) };
}
const methods = {
get: { call: function() {
uci = cursor();
return read_config();
} },
status: { call: function() {
return status();
} },
prepare: { call: function(input) {
uci = cursor();
if (!section_exists(IFACE, 'interface') || !section_exists(PEER, 'wireguard_wg0'))
return fail('REQUIRED_SECTION_MISSING');
let normalized = normalize_input(input || {});
if (!normalized.ok)
return normalized;
let current = read_config();
let diff = diff_values(current, normalized.values);
let token = trim(readfile('/proc/sys/kernel/random/uuid') || `${uptime()}`);
write_token({ token, expires: uptime() + 60, generation: current.generation, values: normalized.values });
return { ok: true, token, expires_in: 60, diff, requires_confirm: true };
} },
apply: { call: function(input) {
uci = cursor();
input ||= {};
let state = read_token();
if (state == null || input.token != state.token || input.confirm !== true)
return fail('INVALID_TOKEN');
if (uptime() > state.expires)
return fail('TOKEN_EXPIRED');
if (generation() != state.generation)
return fail('CONFIG_GENERATION_CHANGED');
if (!sh(`mkdir '${LOCK}' 2>/dev/null`).ok)
return fail('APPLY_LOCKED');
let runtime = null;
try {
delete_token();
if (!snapshot()) {
sh(`rmdir '${LOCK}' 2>/dev/null`);
return fail('SNAPSHOT_FAILED');
}
apply_values(state.values);
reconnect_wg0();
runtime = check_runtime();
if (!runtime.ok) {
restore_snapshot();
reconnect_wg0();
sh(`rmdir '${LOCK}' 2>/dev/null`);
return { ok: false, code: 'RUNTIME_CHECK_FAILED_ROLLED_BACK', runtime };
}
sh(`rmdir '${LOCK}' 2>/dev/null`);
return { ok: true, runtime };
} catch (e) {
restore_snapshot();
reconnect_wg0();
sh(`rmdir '${LOCK}' 2>/dev/null`);
return fail('APPLY_EXCEPTION_ROLLED_BACK');
}
} },
rollback: { call: function() {
let ok = restore_snapshot();
if (ok)
reconnect_wg0();
return { ok, runtime: check_runtime() };
} },
reconnect: { call: function() {
let ok = reconnect_wg0();
return { ok, runtime: check_runtime() };
} }
};
return { 'luci.wgtunnel': methods };
@@ -0,0 +1,24 @@
#
# Copyright (C) 2008-2019 Jerrykuku
#
# This is free software, licensed under the Apache License, Version 2.0 .
#
include $(TOPDIR)/rules.mk
LUCI_TITLE:=Argon Theme
LUCI_DEPENDS:=+wget +jsonfilter
PKG_VERSION:=2.4.3
PKG_RELEASE:=20250722
LUCI_MINIFY_CSS:=0
define Package/luci-theme-argon/conffiles
/etc/config/argon
/www/luci-static/argon/background/
endef
include $(TOPDIR)/feeds/luci/luci.mk
# call BuildPackage - OpenWrt buildroot signature
@@ -0,0 +1,2 @@
Drop background here!
accept jpg png gif mp4 webm
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<path fill="#adaeaf" d="m8,10.033663l-6.898535,-6.013274l-1.060688,0.972974l7.959223,6.986249l7.959223,-6.986249l-1.060688,-0.972974l-6.898535,6.013274z"/>
</svg>

After

Width:  |  Height:  |  Size: 225 B

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig><msapplication><tile><square70x70logo src="/ms-icon-70x70.png"/><square150x150logo src="/ms-icon-150x150.png"/><square310x310logo src="/ms-icon-310x310.png"/><TileColor>#ffffff</TileColor></tile></msapplication></browserconfig>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

@@ -0,0 +1,41 @@
{
"name": "Openwrt",
"icons": [
{
"src": "\/android-icon-36x36.png",
"sizes": "36x36",
"type": "image\/png",
"density": "0.75"
},
{
"src": "\/android-icon-48x48.png",
"sizes": "48x48",
"type": "image\/png",
"density": "1.0"
},
{
"src": "\/android-icon-72x72.png",
"sizes": "72x72",
"type": "image\/png",
"density": "1.5"
},
{
"src": "\/android-icon-96x96.png",
"sizes": "96x96",
"type": "image\/png",
"density": "2.0"
},
{
"src": "\/android-icon-144x144.png",
"sizes": "144x144",
"type": "image\/png",
"density": "3.0"
},
{
"src": "\/android-icon-192x192.png",
"sizes": "192x192",
"type": "image\/png",
"density": "4.0"
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<path fill="#888" d="M8,0c-4.355,0-7.898,3.481-7.998,7.812,0.092-3.779,2.966-6.812,6.498-6.812,3.59,0,6.5,3.134,6.5,7,0,0.828,0.672,1.5,1.5,1.5s1.5-0.672,1.5-1.5c0-4.418-3.582-8-8-8zM8,16c4.355,0,7.898-3.481,7.998-7.812-0.092,3.779-2.966,6.812-6.498,6.812-3.59,0-6.5-3.134-6.5-7,0-0.828-0.672-1.5-1.5-1.5s-1.5,0.672-1.5,1.5c0,4.418,3.582,8,8,8z"/>
</svg>

After

Width:  |  Height:  |  Size: 417 B

@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 27.5.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="图层_1" xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 256 256"
style="enable-background:new 0 0 256 256;" xml:space="preserve">
<style type="text/css">
.st0{fill-rule:evenodd;clip-rule:evenodd;fill:url(#svg_2_00000009581766544743910510000007087157279682564742_);}
.st1{fill-rule:evenodd;clip-rule:evenodd;fill:url(#svg_3_00000013155245276689480680000010334395393893521599_);}
.st2{fill:#FFFFFF;}
</style>
<g>
<g id="svg_1">
<linearGradient id="svg_2_00000043442590260727270070000016472210641679865270_" gradientUnits="userSpaceOnUse" x1="11.1563" y1="247.3437" x2="245.4437" y2="13.0563" gradientTransform="matrix(1 0 0 -1 0 258)">
<stop offset="0" style="stop-color:#5E72E4"/>
<stop offset="1" style="stop-color:#778AFF"/>
</linearGradient>
<path id="svg_2" style="fill-rule:evenodd;clip-rule:evenodd;fill:url(#svg_2_00000043442590260727270070000016472210641679865270_);" d="
M36.4,0.2h183.8c19.7,0,35.7,16,35.7,35.7v183.8c0,19.7-16,35.7-35.7,35.7H36.4c-19.7,0-35.7-16-35.7-35.7V35.9
C0.7,16.2,16.7,0.2,36.4,0.2z"/>
<linearGradient id="svg_3_00000010280352489557108120000000938545297310085033_" gradientUnits="userSpaceOnUse" x1="0.7" y1="257.8" x2="0.7" y2="257.8" gradientTransform="matrix(1 0 0 -1 0 258)">
<stop offset="0" style="stop-color:#5E72E4"/>
<stop offset="1" style="stop-color:#778AFF"/>
</linearGradient>
<path id="svg_3" style="fill-rule:evenodd;clip-rule:evenodd;fill:url(#svg_3_00000010280352489557108120000000938545297310085033_);" d="
M0.7,0.2"/>
</g>
<path id="svg_4" class="st2" d="M128.3,45.4c-46.7,0-84.4,37.8-84.4,84.4c0,32.2,18.1,60.2,44.6,74.4c6.8,3.7,15.3-0.2,17.2-7.7
l4.3-17.6c1.5-6.2-1-12.6-6.1-16.4c-10-7.4-16.4-19.3-16.4-32.7c0-22.5,18.3-40.7,40.7-40.7c22.5,0,40.7,18.3,40.7,40.7
c0,13.4-6.4,25.2-16.4,32.7c-5.1,3.8-7.6,10.2-6.1,16.5l4.4,17.6c1.9,7.5,10.3,11.4,17.2,7.7c26.6-14.2,44.6-42.2,44.6-74.5
C212.8,83.3,174.9,45.4,128.3,45.4L128.3,45.4z"/>
</g>
<circle class="st2" cx="128.3" cy="131.6" r="18.3"/>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 938 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" class="icon" viewBox="0 0 1024 1024">
<path fill="#fff" d="M484.430769 51.2 236.307692 354.461538H118.153846c-43.323077 0-78.769231 35.446154-78.769231 78.769231v157.538462c0 43.323077 35.446154 78.769231 78.769231 78.769231h118.153846L484.430769 972.8c25.6 25.6 66.953846 7.876923 66.953846-27.569231V78.769231c0-35.446154-43.323077-53.169231-66.953846-27.569231zm354.461539 120.123077c-7.876923-7.876923-19.692308-7.876923-27.569231 0l-27.569231 27.569231c-7.876923 7.876923-7.876923 21.661538 0 27.56923C858.584615 299.323077 905.846154 399.753846 905.846154 512c0 112.246154-47.261538 212.676923-122.092308 285.538462-7.876923 7.876923-7.876923 19.692308 0 27.56923l27.569231 27.569231c7.876923 7.876923 19.692308 7.876923 27.569231 0C927.507692 768 984.615385 645.907692 984.615385 512s-55.138462-256-145.723077-340.676923zM714.830769 297.353846c-7.876923-7.876923-19.692308-7.876923-27.569231 0l-27.56923 27.569231c-7.876923 7.876923-7.876923 19.692308 0 27.569231C703.015385 391.876923 728.615385 448.984616 728.615385 512c0 63.015385-27.569231 120.123077-70.892308 159.507692-7.876923 7.876923-7.876923 19.692308 0 27.569231l27.569231 27.569231c7.876923 7.876923 19.692308 7.876923 27.56923 0 57.107692-53.169231 94.523077-129.969231 94.523077-216.615385 0-82.707692-35.446154-159.507692-92.553846-212.676923z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" class="icon" viewBox="0 0 1024 1024">
<path fill="#fff" d="M484.430769 51.2 236.307692 354.461538H118.153846c-43.323077 0-78.769231 35.446154-78.769231 78.769231v157.538462c0 43.323077 35.446154 78.769231 78.769231 78.769231h118.153846L484.430769 972.8c25.6 25.6 66.953846 7.876923 66.953846-27.569231V78.769231c0-35.446154-43.323077-53.169231-66.953846-27.569231zM882.215385 512l96.492307-96.492308c7.876923-7.876923 7.876923-19.692308 0-27.56923l-27.56923-27.569231c-7.876923-7.876923-19.692308-7.876923-27.569231 0l-96.492308 96.492307-96.492308-96.492307c-7.876923-7.876923-19.692308-7.876923-27.56923 0l-27.569231 27.569231c-7.876923 7.876923-7.876923 19.692308 0 27.56923L771.938462 512l-96.492308 96.492308c-7.876923 7.876923-7.876923 19.692308 0 27.56923l27.569231 27.569231c7.876923 7.876923 19.692308 7.876923 27.56923 0l96.492308-96.492307 96.492308 96.492307c7.876923 7.876923 19.692308 7.876923 27.569231 0l27.56923-27.569231c7.876923-7.876923 7.876923-19.692308 0-27.56923L882.215385 512z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1,448 @@
'use strict';
'require baseclass';
'require ui';
/**
* Native JavaScript slide animation utilities
* Replaces jQuery slideUp/slideDown functionality with better performance
*/
const SlideAnimations = {
/**
* Animation durations in milliseconds
*/
durations: {
fast: 200,
normal: 400,
slow: 600
},
/**
* Map to track running animations and their cleanup functions
*/
runningAnimations: new WeakMap(),
/**
* Slide element down (show) with animation
* @param {Element} element - DOM element to animate
* @param {string|number} duration - Animation duration ('fast', 'normal', 'slow' or milliseconds)
* @param {function} callback - Optional callback function when animation completes
*/
slideDown: function(element, duration, callback) {
if (!element) {
console.warn('SlideAnimations.slideDown: No element provided');
return;
}
// Stop any existing animation on this element
this.stop(element);
// Convert duration string to milliseconds
const animDuration = typeof duration === 'string' ?
this.durations[duration] || this.durations.normal :
(duration || this.durations.normal);
// Store original styles
const originalStyles = {
display: element.style.display,
overflow: element.style.overflow,
height: element.style.height,
transition: element.style.transition
};
// Set initial state for animation
element.style.display = 'block';
element.style.overflow = 'hidden';
element.style.height = '0px';
element.style.transition = `height ${animDuration}ms ease-out`;
// Force reflow to ensure initial state is applied
element.offsetHeight;
// Get the target height
const targetHeight = element.scrollHeight;
// Animate to full height
element.style.height = targetHeight + 'px';
// Set up cleanup function
const cleanup = () => {
element.style.height = originalStyles.height || '';
element.style.overflow = originalStyles.overflow || '';
element.style.transition = originalStyles.transition || '';
// Remove from running animations map
this.runningAnimations.delete(element);
if (callback && typeof callback === 'function') {
try {
callback.call(element);
} catch (e) {
console.error('SlideAnimations callback error:', e);
}
}
};
// Store cleanup function for potential cancellation
const timeoutId = setTimeout(cleanup, animDuration);
this.runningAnimations.set(element, { timeoutId, cleanup });
},
/**
* Slide element up (hide) with animation
* @param {Element} element - DOM element to animate
* @param {string|number} duration - Animation duration ('fast', 'normal', 'slow' or milliseconds)
* @param {function} callback - Optional callback function when animation completes
*/
slideUp: function(element, duration, callback) {
if (!element) {
console.warn('SlideAnimations.slideUp: No element provided');
return;
}
// Stop any existing animation on this element
this.stop(element);
// Convert duration string to milliseconds
const animDuration = typeof duration === 'string' ?
this.durations[duration] || this.durations.normal :
(duration || this.durations.normal);
// Store original styles
const originalStyles = {
display: element.style.display,
overflow: element.style.overflow,
height: element.style.height,
transition: element.style.transition
};
// Get current height before hiding
const currentHeight = element.scrollHeight;
// Set initial state for animation
element.style.overflow = 'hidden';
element.style.height = currentHeight + 'px';
element.style.transition = `height ${animDuration}ms ease-out`;
// Force reflow to ensure initial state is applied
element.offsetHeight;
// Animate to zero height
element.style.height = '0px';
// Set up cleanup function
const cleanup = () => {
element.style.display = 'none';
element.style.height = originalStyles.height || '';
element.style.overflow = originalStyles.overflow || '';
element.style.transition = originalStyles.transition || '';
// Remove from running animations map
this.runningAnimations.delete(element);
if (callback && typeof callback === 'function') {
try {
callback.call(element);
} catch (e) {
console.error('SlideAnimations callback error:', e);
}
}
};
// Store cleanup function for potential cancellation
const timeoutId = setTimeout(cleanup, animDuration);
this.runningAnimations.set(element, { timeoutId, cleanup });
},
/**
* Stop all running animations on an element
* @param {Element} element - DOM element to stop animations on
*/
stop: function(element) {
if (!element) return;
const animationData = this.runningAnimations.get(element);
if (animationData) {
// Clear the timeout
clearTimeout(animationData.timeoutId);
// Run cleanup immediately
animationData.cleanup();
}
// Clear transition to immediately stop any CSS animation
element.style.transition = '';
// Force reflow to apply changes immediately
element.offsetHeight;
},
/**
* Check if element has running animation
* @param {Element} element - DOM element to check
* @returns {boolean} - True if element has running animation
*/
isAnimating: function(element) {
return this.runningAnimations.has(element);
}
};
/**
* Argon Theme Menu Module
* Handles rendering and interaction of the main navigation menu and sidebar
*/
return baseclass.extend({
/**
* Initialize the menu module
* Load menu data and trigger rendering
*/
__init__: function () {
ui.menu.load().then(L.bind(this.render, this));
},
/**
* Main render function for the menu system
* @param {Object} tree - Menu tree structure from LuCI
*/
render: function (tree) {
var node = tree,
url = '',
children = ui.menu.getChildren(tree);
// Find and render the active main menu item
for (var i = 0; i < children.length; i++) {
var isActive = (L.env.requestpath.length ? children[i].name == L.env.requestpath[0] : i == 0);
if (isActive) {
this.renderMainMenu(children[i], children[i].name);
}
}
// Render tab menu if we're deep enough in the navigation hierarchy
if (L.env.dispatchpath.length >= 3) {
for (var i = 0; i < 3 && node; i++) {
node = node.children[L.env.dispatchpath[i]];
url = url + (url ? '/' : '') + L.env.dispatchpath[i];
}
if (node) {
this.renderTabMenu(node, url);
}
}
// Attach event listeners for sidebar toggle functionality
var sidebarToggle = document.querySelector('a.showSide');
var darkMask = document.querySelector('.darkMask');
if (sidebarToggle) {
sidebarToggle.addEventListener('click', ui.createHandlerFn(this, 'handleSidebarToggle'));
}
if (darkMask) {
darkMask.addEventListener('click', ui.createHandlerFn(this, 'handleSidebarToggle'));
}
},
/**
* Handle menu expand/collapse functionality
* Manages the sliding animation and active states of menu items
* @param {Event} ev - Click event from menu item
*/
handleMenuExpand: function (ev) {
var target = ev.target;
var slide = target.parentNode;
var slideMenu = target.nextElementSibling;
var shouldCollapse = false;
// Close all currently active submenus
var activeMenus = document.querySelectorAll('.main .main-left .nav > li > ul.active');
activeMenus.forEach(function (ul) {
// Stop any running animations and slide up
SlideAnimations.stop(ul);
// Remove active classes immediately when starting slideUp animation
ul.classList.remove('active');
ul.previousElementSibling.classList.remove('active');
SlideAnimations.slideUp(ul, 'fast');
// Check if we're clicking on an already open menu (should collapse it)
if (!shouldCollapse && ul === slideMenu) {
shouldCollapse = true;
}
});
// Exit if there's no submenu to show
if (!slideMenu) {
return;
}
// Open the submenu if it's not already open
if (!shouldCollapse) {
// Find the slide menu within the slide element
var slideMenuElement = slide.querySelector(".slide-menu");
if (slideMenuElement) {
// Add active classes immediately when starting slideDown animation
slideMenu.classList.add('active');
target.classList.add('active');
SlideAnimations.slideDown(slideMenuElement, 'fast');
}
target.blur(); // Remove focus from the clicked element
}
// Prevent default link behavior and event bubbling
ev.preventDefault();
ev.stopPropagation();
},
/**
* Render the main navigation menu
* Creates hierarchical menu structure with active states and click handlers
* @param {Object} tree - Menu tree node to render
* @param {string} url - Base URL for menu items
* @param {number} level - Current nesting level (0-based)
* @returns {Element} - Generated menu element
*/
renderMainMenu: function (tree, url, level) {
var currentLevel = (level || 0) + 1;
var menuContainer = E('ul', { 'class': level ? 'slide-menu' : 'nav' });
var children = ui.menu.getChildren(tree);
// Don't render empty menus or menus deeper than 2 levels
if (children.length === 0 || currentLevel > 2) {
return E([]);
}
// Generate menu items for each child
for (var i = 0; i < children.length; i++) {
var child = children[i];
var isActive = (
(L.env.dispatchpath[currentLevel] === child.name) &&
(L.env.dispatchpath[currentLevel - 1] === tree.name)
);
// Recursively render submenu
var submenu = this.renderMainMenu(child, url + '/' + child.name, currentLevel);
var hasChildren = submenu.children.length > 0;
// Determine CSS classes based on state
var slideClass = hasChildren ? 'slide' : null;
var menuClass = hasChildren ? 'menu' : 'food';
if (isActive) {
menuContainer.classList.add('active');
slideClass += " active";
menuClass += " active";
}
// Create menu item with link and submenu
var menuItem = E('li', { 'class': slideClass }, [
E('a', {
'href': L.url(url, child.name),
'click': (currentLevel === 1) ? ui.createHandlerFn(this, 'handleMenuExpand') : null,
'class': menuClass,
'data-title': child.title.replace(/ /g, "_"), // More robust space replacement
}, [_(child.title)]),
submenu
]);
menuContainer.appendChild(menuItem);
}
// Append to main menu container if this is the top level
if (currentLevel === 1) {
var mainMenuElement = document.querySelector('#mainmenu');
if (mainMenuElement) {
mainMenuElement.appendChild(menuContainer);
mainMenuElement.style.display = '';
}
}
return menuContainer;
},
/**
* Render tab navigation menu
* Creates horizontal tab menu for deeper navigation levels
* @param {Object} tree - Menu tree node to render
* @param {string} url - Base URL for tab items
* @param {number} level - Current nesting level (0-based)
* @returns {Element} - Generated tab menu element
*/
renderTabMenu: function (tree, url, level) {
var container = document.querySelector('#tabmenu');
var currentLevel = (level || 0) + 1;
var tabContainer = E('ul', { 'class': 'tabs' });
var children = ui.menu.getChildren(tree);
var activeNode = null;
// Don't render empty tab menus
if (children.length === 0) {
return E([]);
}
// Generate tab items for each child
for (var i = 0; i < children.length; i++) {
var child = children[i];
var isActive = (L.env.dispatchpath[currentLevel + 2] === child.name);
var activeClass = isActive ? ' active' : '';
var className = 'tabmenu-item-%s %s'.format(child.name, activeClass);
var tabItem = E('li', { 'class': className }, [
E('a', { 'href': L.url(url, child.name) }, [_(child.title)])
]);
tabContainer.appendChild(tabItem);
// Store reference to active node for recursive rendering
if (isActive) {
activeNode = child;
}
}
// Append tab container to main tab menu element
if (container) {
container.appendChild(tabContainer);
container.style.display = '';
// Recursively render nested tab menus if there's an active node
if (activeNode) {
var nestedTabs = this.renderTabMenu(activeNode, url + '/' + activeNode.name, currentLevel);
if (nestedTabs.children.length > 0) {
container.appendChild(nestedTabs);
}
}
}
return tabContainer;
},
/**
* Handle sidebar toggle functionality
* Toggles the mobile/responsive sidebar menu visibility
* @param {Event} ev - Click event from sidebar toggle button or dark mask
*/
handleSidebarToggle: function (ev) {
var showSideButton = document.querySelector('a.showSide');
var sidebar = document.querySelector('#mainmenu');
var darkMask = document.querySelector('.darkMask');
var scrollbarArea = document.querySelector('.main-right');
// Check if any required elements are missing
if (!showSideButton || !sidebar || !darkMask || !scrollbarArea) {
console.warn('Sidebar toggle elements not found');
return;
}
// Toggle sidebar visibility and related states
if (showSideButton.classList.contains('active')) {
// Close sidebar
showSideButton.classList.remove('active');
sidebar.classList.remove('active');
scrollbarArea.classList.remove('active');
darkMask.classList.remove('active');
} else {
// Open sidebar
showSideButton.classList.add('active');
sidebar.classList.add('active');
scrollbarArea.classList.add('active');
darkMask.classList.add('active');
}
}
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,38 @@
// out: false
// ==========================================================================
// CSS Custom Properties for Font Management
// ==========================================================================
@font-face {
font-family: 'Google Sans';
src: local('Google Sans'),
local('GoogleSans-Regular'),
url('../fonts/GoogleSans-Regular.woff2') format('woff2'),
url('../fonts/GoogleSans-Regular.woff') format('woff');
font-weight: normal;
font-style: normal;
font-display: swap;
}
/* Logo Font */
@font-face {
font-family: 'TypoGraphica';
src: local('TypoGraphica'),
url('../fonts/TypoGraphica.woff2') format('woff2'),
url('../fonts/TypoGraphica.woff') format('woff');
font-weight: normal;
font-style: normal;
font-display: swap;
}
/* Icon Font */
@font-face {
font-family: 'argon';
src: url('../fonts/argon.woff2') format('woff2'),
url('../fonts/argon.woff') format('woff');
font-weight: normal;
font-style: normal;
font-display: swap;
}
@@ -0,0 +1,605 @@
// out: false
// ==========================================================================
// Layout Styles
// Main layout components for LuCI admin interface
// ==========================================================================
// 1. Main Container Layout
// ==========================================================================
.main {
position: relative;
width: 100%;
height: 100%;
overflow-y: auto;
display: flex;
flex-direction: row;
}
// 2. Left Sidebar Layout
// ==========================================================================
.main-left {
width: 15rem;
height: 100%;
flex-shrink: 0;
z-index: 100;
overflow-x: auto;
word-break: break-word;
background-color: var(--menu-bg-color);
box-shadow: rgba(0, 0, 0, 0.75) 0px 0px 15px -5px;
transition: all 0.2s;
// Sidebar Header
.sidenav-header {
padding: 1.5rem 0.5rem;
text-align: center;
.brand {
display: block;
margin: 0 2rem;
font-size: 1.8rem;
font-family: "TypoGraphica";
color: var(--primary);
text-decoration: none;
text-align: center;
cursor: default;
.logo {
max-width: 100%;
height: auto;
}
}
}
// Navigation Menu
.nav {
margin-top: 0.5rem;
// Top-level navigation links
&>li>a:first-child {
display: block;
position: relative;
margin: 0.1rem 0.5rem;
padding: 0.675rem 0 0.675rem 2.5rem;
font-size: 1rem;
text-decoration: none;
border-radius: 0.25rem;
cursor: default;
transition: all 0.2s;
// Active state
&.active {
color: #fff;
background: var(--primary);
&::before {
color: #fff !important;
}
&::after {
transform: rotate(90deg);
color: #fff !important;
}
}
// Hover state
&:hover {
cursor: pointer;
color: #fff;
background: var(--primary);
&::before {
color: #fff !important;
}
}
// Icon before text
&::before {
position: absolute;
left: 0.8rem;
padding-top: 3px;
font-family: 'argon' !important;
font-style: normal;
font-weight: normal;
font-variant: normal;
text-transform: none;
line-height: 1;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
content: "\e915";
color: var(--primary);
transition: all 0.3s;
}
}
&>.slide>.menu::before {
transition: transform .1s ease-in-out;
}
&>.slide>.menu.active::before {
transition: transform .2s ease-in-out;
}
// Navigation list items
li {
padding: 0;
cursor: pointer;
user-select: none;
a {
display: block;
color: var(--menu-color);
}
// Collapsible menu items
&.slide {
padding: 0;
ul {
display: none;
overflow: hidden;
}
&:hover {
background: none;
}
// Submenu container
.slide-menu {
margin: 0 0.5rem 0 2.5rem;
padding: 0rem 0.5rem;
&.active {
display: block;
}
li {
position: relative;
margin: 0;
border-radius: 0.25rem;
background: none;
list-style: none;
a {
padding: 0.5rem 0rem;
text-decoration: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
// Hover indicator line
&::after {
position: absolute;
left: 0;
bottom: 0;
width: 0;
height: 2px;
content: "";
background-color: var(--primary);
transition: all 0.2s;
}
&:hover {
background: none;
&::after {
width: 100%;
}
}
}
// Active submenu item
.active {
background: none;
color: var(--menu-color);
a {
color: var(--menu-color);
}
&::after {
position: absolute;
left: 0;
bottom: 0;
width: 100%;
height: 2px;
content: "";
background-color: var(--primary);
transition: all 0.2s;
}
&:hover {
background: none;
&::after {
width: 100%;
}
}
}
}
}
// Main menu items
.menu {
display: block;
position: relative;
margin: 0.1rem 0.5rem;
padding: 0.675rem 0 0.675rem 2.5rem;
font-size: 1rem;
text-decoration: none;
border-radius: 0.25rem;
cursor: default;
transition: all 0.2s;
// Active state
&.active {
color: #fff;
background: var(--primary);
&::before {
color: #fff !important;
}
&::after {
transform: rotate(90deg);
color: #fff !important;
}
}
// Hover state
&:hover {
cursor: pointer;
color: #fff;
background: var(--primary);
&::before {
color: #fff !important;
}
}
// Icon before text
&::before {
position: absolute;
left: 0.8rem;
padding-top: 3px;
font-family: 'argon' !important;
font-style: normal;
font-weight: normal;
font-variant: normal;
text-transform: none;
line-height: 1;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
content: "\e915";
color: var(--primary);
transition: all 0.3s;
}
// Dropdown arrow
&::after {
position: absolute;
right: 0.5rem;
top: 0.8rem;
font-family: 'argon' !important;
font-style: normal;
font-weight: normal;
font-variant: normal;
text-transform: none;
line-height: 1;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
content: '\e90f';
color: #ced4da;
text-rendering: auto;
transition: all 0.3s;
}
}
// Menu-specific icons and colors
.menu[data-title="Status"]:before {
content: "\e906";
color: var(--primary);
}
.menu[data-title="System"]:before {
content: "\e90a";
color: #fb6340;
}
.menu[data-title="Services"]:before {
content: "\e909";
color: #11cdef;
}
.menu[data-title="NAS"]:before {
content: "\e90c";
color: #f3a4b5;
}
.menu[data-title="VPN"]:before {
content: "\e90b";
color: #8965e0;
}
.menu[data-title="Network"]:before {
content: "\e908";
color: #8965e0;
}
.menu[data-title="Bandwidth_Monitor"]:before {
content: "\e90d";
color: #2dce89;
}
.menu[data-title="Docker"]:before {
content: "\e911";
color: #6699ff;
}
.menu[data-title="Statistics"]:before {
content: "\e913";
color: #8965e0;
}
.menu[data-title="Control"]:before {
content: "\e912";
color: var(--primary);
}
.menu[data-title="Asterisk"]:before {
content: "\e914";
color: #fb6340;
}
// Logout link styling
a[data-title="Log_out"],
.food[data-title="Log_out"] {
&::before {
content: "\e907";
color: #adb5bd;
}
}
}
}
&[style*="overflow: hidden"]>.nav>.slide>.menu::before {
display: none;
}
// Scrollbar Styling
&::-webkit-scrollbar {
width: 5px;
height: 1px;
}
&::-webkit-scrollbar-thumb {
background-color: #f6f9fc;
}
&::-webkit-scrollbar-track {
background-color: #fff;
}
}
// 3. Right Content Area Layout
// ==========================================================================
.main-right {
height: 100%;
flex-grow: 1;
overflow-x: hidden;
overflow-y: auto;
display: flex;
flex-direction: column;
transition: all 0.2s;
// Main Content Container
&>#maincontent {
position: relative;
z-index: 50;
flex: 1;
display: flex;
flex-direction: column;
&>.container {
margin: 0 1.25rem 1rem 1.25rem;
flex-grow: 1;
display: flex;
flex-direction: column;
gap: 1rem;
}
// Dashboard Specific Styles
.Dashboard {
color: var(--gray-dark) !important;
h3 {
color: var(--gray-dark);
}
p {
margin-top: 3px;
margin-bottom: 3px;
}
hr {
border-top: 1px solid rgba(0, 0, 0, 1);
}
.dashboard-bg {
background-color: #fff;
}
.settings-info {
padding-top: 1em;
padding-bottom: 1em;
p span:nth-child(2) {
max-height: 18.5px;
top: 4px;
}
.label {
font-size: 0.7rem;
padding: 0.2rem 0.6rem;
}
}
}
}
}
// 4. Header Layout
// ==========================================================================
header {
position: relative;
width: 100%;
padding: 0;
color: var(--header-color);
// Primary background modifier
&.bg-primary {
background-color: var(--primary) !important;
}
// Header extension background
&::after {
content: "";
position: absolute;
width: 100%;
height: 2rem;
background-color: var(--primary) !important;
}
// Header Content Container
.fill {
padding: 0.8rem 0;
display: flex;
border-bottom: 0px solid rgba(255, 255, 255, 0.08) !important;
.container {
width: 100%;
height: 2rem;
padding: 0 1.25rem;
display: flex;
align-items: center;
// Left section (brand and menu toggle)
.flex1 {
flex: 1;
.showSide {
display: none;
font-size: 1.4rem;
color: #fff;
&:hover {
text-decoration: none;
}
}
.brand {
display: none;
padding-left: 1rem;
font-size: 1.5rem;
font-family: "TypoGraphica";
color: #fff;
text-decoration: none;
cursor: default;
vertical-align: text-bottom;
}
}
// Right section (status indicators)
.pull-right {
margin-top: 0rem;
float: right;
display: flex;
}
}
// Status Indicators
.status {
span {
display: inline-block;
margin: 0 0.25rem;
padding: 0.3rem 0.8rem;
font-size: 0.875rem;
font-weight: bold;
white-space: nowrap;
text-decoration: none;
text-transform: uppercase;
text-shadow: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.3s;
box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.16), 0 0 2px 0 rgba(0, 0, 0, 0.12);
&:last-child {
margin-right: 0;
}
}
span[data-indicator="poll-status"] {
color: #fff;
}
span[data-style="active"] {
background-color: var(--green);
}
span[data-style="inactive"] {
color: #ffffff !important;
background-color: #32325d;
}
}
}
}
// 5. Footer Layout
// ==========================================================================
footer {
padding: 1rem;
font-size: 0.875rem;
color: #aaa;
text-align: right;
white-space: nowrap;
overflow: hidden;
>a {
color: #aaa;
text-decoration: none;
}
}
// 6. View
// ==========================================================================
#view {
border-radius: 0.25rem;
display: flex;
flex-direction: column;
gap: 1rem;
&>.spinning {
position: fixed;
top: 50%;
left: 50%;
transform: translateX(-50%) translateY(-50%);
padding: 1rem;
border-radius: 0.5rem;
background: #ffffff;
box-shadow: 0 0 1rem 0 rgba(136, 152, 170, .15);
}
&>div:first-child {
display: flex;
flex-direction: column;
gap: 0.875rem;
}
}
@@ -0,0 +1,407 @@
// out: false
// ==========================================================================
// Normalize.less
// Based on normalize.css for style reset and standardization
// ==========================================================================
// 1. Global Reset
// ==========================================================================
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
// 2. Document Root Elements
// ==========================================================================
html {
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
html,
body {
margin: 0;
padding: 0;
height: 100%;
font-family: var(--font-family-sans-serif);
}
body {
background-color: var(--background-color);
color: var(--gray-dark);
-webkit-tap-highlight-color: transparent;
font-size: 0.875rem;
}
// 3. Semantic Elements
// ==========================================================================
main {
display: block;
}
// 4. Heading Elements
// ==========================================================================
.h1,
.h2,
.h3,
.h4,
.h5,
.h6,
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: inherit;
font-weight: normal;
line-height: 1.1 !important;
color: inherit;
}
h1 {
font-size: 2rem;
margin: 0.67em 0;
padding-bottom: 0.5rem;
border-bottom: thin solid var(--lighter);
}
h2 {
padding: 1rem 1.25rem;
font-size: 1.25rem;
font-weight: bold;
color: var(--gray-dark);
border-radius: 0.25rem;
background: var(--white);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.03);
}
h3 {
margin: 0;
padding: 0.8755rem 1.25rem;
font-size: 1.1rem;
font-weight: bold;
line-height: 1;
color: var(--gray-dark);
border-radius: 0.25rem;
background: var(--white);
display: block;
width: 100%;
}
h4 {
margin: 0;
padding: 0.75rem 1.25rem;
font-size: 0.875rem;
font-weight: bold;
color: var(--gray-dark-400);
em {
padding: 0 0.5rem;
}
}
h5 {
margin: 2rem 0 0 0;
padding-bottom: 0.5rem;
font-size: 1rem;
}
// 5. Text Elements
// ==========================================================================
a {
background-color: transparent;
}
abbr {
cursor: help;
text-decoration: underline;
color: var(--primary);
}
abbr[title] {
border-bottom: none;
text-decoration: underline;
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
}
b,
strong {
font-weight: bolder;
}
code,
kbd,
samp {
font-family: inherit;
font-size: inherit;
padding: 1px 3px;
color: var(--dark);
border-radius: 0.5rem;
background: var(--lighter);
}
small {
font-size: 90%;
line-height: 1.42857143;
white-space: normal;
}
sub,
sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
// 6. Horizontal Rules and Preformatted Text
// ==========================================================================
hr {
box-sizing: content-box;
height: 0;
margin: 1rem 0;
overflow: visible;
opacity: 0.1;
border-color: var(--lighter);
}
pre {
font-family: monospace, monospace;
font-size: 1em;
overflow: auto;
}
// 7. Media Elements
// ==========================================================================
img {
border-style: none;
}
// 8. Form Elements
// ==========================================================================
button,
input,
optgroup,
select,
textarea {
font-family: inherit;
font-size: 100%;
line-height: 1.15;
margin: 0;
}
button,
input {
overflow: visible;
}
button,
select {
text-transform: none;
}
// Button Styles
[type=button],
[type=reset],
[type=submit],
button {
-webkit-appearance: button;
appearance: button;
}
[type=button]::-moz-focus-inner,
[type=reset]::-moz-focus-inner,
[type=submit]::-moz-focus-inner,
button::-moz-focus-inner {
border-style: none;
padding: 0;
}
[type=button]:-moz-focusring,
[type=reset]:-moz-focusring,
[type=submit]:-moz-focusring,
button:-moz-focusring {
outline: 1px dotted ButtonText;
}
// Input Styles
[type=checkbox],
[type=radio] {
box-sizing: border-box;
padding: 0;
}
[type=number]::-webkit-inner-spin-button,
[type=number]::-webkit-outer-spin-button {
height: auto;
}
[type=search] {
-webkit-appearance: textfield;
appearance: textfield;
outline-offset: -2px;
}
[type=search]::-webkit-search-decoration {
-webkit-appearance: none;
appearance: none;
}
::-webkit-file-upload-button {
-webkit-appearance: button;
appearance: button;
font: inherit;
}
// checkbox style
input[type="checkbox"] {
appearance: none !important;
-webkit-appearance: none !important;
border: 1px solid var(--primary);
width: 1rem !important;
height: 1rem !important;
padding: 0;
cursor: pointer;
transition: all 0.2s;
}
input[type="checkbox"]:checked {
border: 1px solid var(--primary);
background-image: url('data:image/svg+xml,%3csvg xmlns=\'http://www.w3.org/2000/svg\' viewBox=\'0 0 8 8\'%3e%3cpath fill=\'%23fff\' d=\'M6.564.75l-3.59 3.612-1.538-1.55L0 4.26 2.974 7.25 8 2.193z\'/%3e%3c/svg%3e') !important;
background-color: var(--primary);
background-size: 70%;
background-repeat: no-repeat;
background-position: center;
}
// Select Styles
select {
padding: 0.36rem 0.8rem;
color: var(--gray-dark);
border: thin solid var(--lighter);
background-color: var(--white);
background-image: none;
}
// Textarea Styles
textarea {
padding: 0.25rem;
overflow: auto;
&:focus-visible {
outline: none;
border: 1px solid var(--primary);
}
}
// Fieldset Styles
fieldset {
padding: 0.35em 0.75em 0.625em;
}
legend {
box-sizing: border-box;
color: inherit;
display: table;
max-width: 100%;
padding: 0;
white-space: normal;
}
// 9. Interactive Elements
// ==========================================================================
details {
display: block;
}
summary {
display: list-item;
}
template {
display: none;
}
progress {
vertical-align: baseline;
}
// 10. List Elements
// ==========================================================================
ul {
line-height: normal;
}
li {
list-style-type: none;
}
// 11. State Styles
// ==========================================================================
[disabled="disabled"] {
pointer-events: none;
}
::selection {
background-color: var(--primary);
color: var(--white);
}
::placeholder {
color: var(--lighter);
}
a:link,
a:visited,
a:active {
color: var(--primary);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
// 12 tools
// ==========================================================================
.pull-right {
float: right;
}
.pull-left {
float: left;
}
.nowrap:not(.td) {
white-space: nowrap;
}
div[style="width:100%;height:300px;border:1px solid #000;background:#fff"] {
border: 0 !important;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,245 @@
// out: false
/**
Pure v3.0.0
Copyright 2013 Yahoo!
Licensed under the BSD License.
https://github.com/pure-css/pure/blob/master/LICENSE
*/
/**
normalize.css v | MIT License | https://necolas.github.io/normalize.css/
Copyright (c) Nicolas Gallagher and Jonathan Neal
*/
/** normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */
.pure-g {
display: flex;
flex-flow: row wrap;
align-content: flex-start
}
.pure-u {
display: inline-block;
vertical-align: top
}
.pure-u-1,
.pure-u-1-1,
.pure-u-1-12,
.pure-u-1-2,
.pure-u-1-24,
.pure-u-1-3,
.pure-u-1-4,
.pure-u-1-5,
.pure-u-1-6,
.pure-u-1-8,
.pure-u-10-24,
.pure-u-11-12,
.pure-u-11-24,
.pure-u-12-24,
.pure-u-13-24,
.pure-u-14-24,
.pure-u-15-24,
.pure-u-16-24,
.pure-u-17-24,
.pure-u-18-24,
.pure-u-19-24,
.pure-u-2-24,
.pure-u-2-3,
.pure-u-2-5,
.pure-u-20-24,
.pure-u-21-24,
.pure-u-22-24,
.pure-u-23-24,
.pure-u-24-24,
.pure-u-3-24,
.pure-u-3-4,
.pure-u-3-5,
.pure-u-3-8,
.pure-u-4-24,
.pure-u-4-5,
.pure-u-5-12,
.pure-u-5-24,
.pure-u-5-5,
.pure-u-5-6,
.pure-u-5-8,
.pure-u-6-24,
.pure-u-7-12,
.pure-u-7-24,
.pure-u-7-8,
.pure-u-8-24,
.pure-u-9-24 {
display: inline-block;
letter-spacing: normal;
word-spacing: normal;
vertical-align: top;
text-rendering: auto
}
.pure-u-1-24 {
width: 4.1667%
}
.pure-u-1-12,
.pure-u-2-24 {
width: 8.3333%
}
.pure-u-1-8,
.pure-u-3-24 {
width: 12.5%
}
.pure-u-1-6,
.pure-u-4-24 {
width: 16.6667%
}
.pure-u-1-5 {
width: 20%
}
.pure-u-5-24 {
width: 20.8333%
}
.pure-u-1-4,
.pure-u-6-24 {
width: 25%
}
.pure-u-7-24 {
width: 29.1667%
}
.pure-u-1-3,
.pure-u-8-24 {
width: 33.3333%
}
.pure-u-3-8,
.pure-u-9-24 {
width: 37.5%
}
.pure-u-2-5 {
width: 40%
}
.pure-u-10-24,
.pure-u-5-12 {
width: 41.6667%
}
.pure-u-11-24 {
width: 45.8333%
}
.pure-u-1-2,
.pure-u-12-24 {
width: 50%
}
.pure-u-13-24 {
width: 54.1667%
}
.pure-u-14-24,
.pure-u-7-12 {
width: 58.3333%
}
.pure-u-3-5 {
width: 60%
}
.pure-u-15-24,
.pure-u-5-8 {
width: 62.5%
}
.pure-u-16-24,
.pure-u-2-3 {
width: 66.6667%
}
.pure-u-17-24 {
width: 70.8333%
}
.pure-u-18-24,
.pure-u-3-4 {
width: 75%
}
.pure-u-19-24 {
width: 79.1667%
}
.pure-u-4-5 {
width: 80%
}
.pure-u-20-24,
.pure-u-5-6 {
width: 83.3333%
}
.pure-u-21-24,
.pure-u-7-8 {
width: 87.5%
}
.pure-u-11-12,
.pure-u-22-24 {
width: 91.6667%
}
.pure-u-23-24 {
width: 95.8333%
}
.pure-u-1,
.pure-u-1-1,
.pure-u-24-24,
.pure-u-5-5 {
width: 100%
}
.col-1 {
flex: 1 1 30px !important;
}
.col-2 {
flex: 2 2 60px !important;
}
.col-3 {
flex: 3 3 90px !important;
}
.col-4 {
flex: 4 4 120px !important;
}
.col-5 {
flex: 5 5 150px !important;
}
.col-6 {
flex: 6 6 180px !important;
}
.col-7 {
flex: 7 7 210px !important;
}
.col-8 {
flex: 8 8 240px !important;
}
.col-9 {
flex: 9 9 270px !important;
}
.col-10 {
flex: 10 10 300px !important;
}
@@ -0,0 +1,610 @@
// out: false
// ==========================================================================
// Responsive Styles
// Mobile-first responsive design for LuCI admin interface
// ==========================================================================
// 1. Browser-Specific Fixes
// ==========================================================================
// Internet Explorer Specific Fixes
@media all and (-ms-high-contrast: none) {
.main>.main-left>.nav>.slide>.menu::before {
top: 30.25%;
}
.main>.main-left>.nav>li:last-child::before {
top: 20%;
}
.showSide::before {
top: -12px;
}
}
// 2. Large Desktop Screens (1600px and below)
// ==========================================================================
@media screen and (max-width: 1600px) {
// Header adjustments
header>.fill>.container>#logo {
margin: 0 2.5rem 0 0.5rem;
}
// Sidebar width
.main-left {
width: calc(0% + 13rem);
}
// Button and label sizing
.btn:not(button),
.label {
padding: 0.5rem 0.75rem;
}
// Form field sizing
.cbi-value-title {
width: 15rem;
padding-right: 0.6rem;
}
.cbi-value-field .cbi-dropdown,
.cbi-value-field .cbi-input-select,
.cbi-value input[type="text"],
.cbi-value input[type="password"],
.cbi-value textarea {
min-width: 18rem;
}
// Specific component adjustments
#cbi-firewall-zone .cbi-input-select {
min-width: 9rem;
}
.cbi-input-textarea {
font-size: small;
}
.node-admin-status>.main fieldset li>a {
padding: 0.3rem 0.6rem;
}
}
// 3. Standard Desktop Screens (1366px and below)
// ==========================================================================
@media screen and (max-width: 1366px) {
// Header adjustments
header>.fill>.container {
cursor: default;
}
// Sidebar width
.main-left {
width: calc(0% + 13rem);
}
// Tab styling
.tabs>li>a,
.cbi-tabmenu>li>a {
padding: 0.2rem 0.8rem;
}
// Panel and table adjustments
.panel-title {
font-size: 1.1rem;
padding-bottom: 1rem;
}
table {
width: 100% !important;
}
.table .cbi-input-text {
width: 100%;
}
// Form field sizing
.cbi-value-field .cbi-dropdown,
.cbi-value-field .cbi-input-select,
.cbi-value input[type="text"],
.cbi-value input[type="password"] {
min-width: 16rem;
}
#cbi-firewall-zone .cbi-input-select {
min-width: 5.5rem;
}
// Navigation font sizes
.main>.main-left>.nav>li,
.main>.main-left>.nav>li>a,
.main .main-left .nav>li>a:first-child,
.main>.main-left>.nav>.slide>.menu,
.main>.main-left>.nav>li>[data-title="Log_out"] {
font-size: 0.9rem;
}
.main>.main-left>.nav>.slide>.slide-menu>li>a {
font-size: 0.875rem;
}
// Modal adjustments
#modal_overlay {
top: 0rem;
}
// Page-specific table fixes
[data-page="admin-network-firewall-forwards"] .table:not(.cbi-section-table) {
display: block;
}
[data-page="admin-network-firewall-forwards"] .table:not(.cbi-section-table),
[data-page="admin-network-firewall-rules"] .table:not(.cbi-section-table),
[data-page="admin-network-hosts"] .table,
[data-page="admin-network-routes"] .table {
overflow-y: visible;
}
// Button sizing
.btn:not(button),
.cbi-button {
font-size: 0.875rem;
}
}
// 4. Small Desktop/Large Tablet (1152px and below)
// ==========================================================================
@media screen and (max-width: 1152px) {
// Header adjustments
header>.fill>.container>#logo {
display: none;
}
header>.fill>.container>.brand {
position: relative;
}
// Layout adjustments
html,
.main {
overflow-y: visible;
}
.main>.loading>span {
top: 25%;
}
.main-left {
width: calc(0% + 13rem);
}
// Login page specific
body:not(.logged-in) .showSide {
visibility: hidden;
width: 0;
margin: 0;
}
.node-main-login>.main .cbi-value-title {
text-align: left;
}
// Form field adjustments
.cbi-value-title {
width: 12rem;
padding-right: 1rem;
}
.cbi-value-field .cbi-dropdown,
.cbi-value-field .cbi-input-select,
.cbi-value input[type="text"] {
width: 16rem;
min-width: 16rem;
}
// Password field specific sizing
.cbi-value input[name^="pw"],
.cbi-value input[data-update="change"]:nth-child(2) {
width: 13rem !important;
min-width: 13rem;
}
// Code output adjustments
#diag-rc-output>pre,
#command-rc-output>pre,
[data-page="admin-services-wol"] .notice code {
font-size: 1rem;
}
// Table responsive behavior
.table {
display: block;
}
.Interfaces .table {
overflow-x: hidden;
}
#packages.table {
display: grid;
}
// Table row adjustments
.tr {
display: flex;
flex-direction: row;
flex-wrap: wrap;
}
.Overview .table[width="100%"]>.tr {
flex-wrap: nowrap;
}
.tr.placeholder {
border-bottom: thin solid #ddd;
}
.tr.placeholder>.td,
#cbi-firewall .tr>.td,
#cbi-network .tr:nth-child(2)>.td,
.cbi-section #wifi_assoclist_table .tr>.td {
border-top: 0;
}
// Table cell adjustments
.th,
.td {
display: inline-block;
align-self: flex-start;
flex: 2 2 10%;
text-overflow: ellipsis;
word-wrap: break-word;
}
.td select,
.td input[type="text"] {
width: 100%;
word-wrap: normal;
}
.td [data-dynlist]>input,
.td input.cbi-input-password {
width: calc(100% - 1.5rem);
}
.td[data-type="button"],
.td[data-type="fvalue"] {
flex: 1 1 12.5%;
text-align: left;
}
.th.cbi-value-field,
.td.cbi-value-field,
.th.cbi-section-table-cell,
.td.cbi-section-table-cell {
flex-basis: auto;
padding-top: 1rem;
}
// Section table adjustments
.cbi-section-table-row {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: space-between;
box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.16), 0 0 2px 0 rgba(0, 0, 0, 0.12);
}
.td.cbi-value-field,
.cbi-section-table-cell {
display: inline-block;
flex: 10 10 auto;
flex-basis: 50%;
text-align: center;
}
.td.cbi-section-actions {
vertical-align: bottom;
}
// Hide table headers on mobile
.tr.table-titles,
.tr.cbi-section-table-titles,
.tr.cbi-section-table-descr {
display: none;
}
.tr[data-title]::before,
.tr.cbi-section-table-titles.named::before {
font-size: 0.9rem;
display: block;
flex: 1 1 100%;
border-bottom: thin solid rgba(0, 0, 0, 0.26);
background: #e9ecef;
}
.td[data-title],
[data-page^="admin-status-realtime"] .td[id] {
text-align: left;
}
.td[data-title]::before {
display: block;
}
// Button spacing
.cbi-button+.cbi-button {
margin-left: 0;
}
.td.cbi-section-actions>*>*,
.td.cbi-section-actions>*>form>* {
margin: 2.1px 3px;
}
// Firewall form adjustments
.Firewall form {
position: static !important;
margin: 0 0 2rem 0;
padding: 2rem;
box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.16), 0 0 2px 0 rgba(0, 0, 0, 0.12);
input {
width: 100% !important;
margin: 0;
margin-top: 1rem;
}
}
.Firewall .center,
.Firewall .center::before {
text-align: left !important;
}
// Button sizing
.btn:not(button),
.cbi-button {
font-size: 0.875rem;
}
}
// 5. Tablet Portrait (768px and below)
// ==========================================================================
@media screen and (max-width: 768px) {
// Base font size
body {
font-size: 0.875rem;
}
// Progress bar adjustments
.cbi-progressbar::after {
font-size: 0.6rem;
}
// Mobile sidebar behavior
.main-left {
position: fixed;
z-index: 100;
width: 0;
&.active {
width: 13rem;
}
}
.main-right {
width: 100%;
&.active {
overflow-y: hidden;
}
}
.darkMask.active {
display: block;
}
// Mobile menu toggle
.showSide {
padding: 0.1rem;
position: relative;
z-index: 99;
display: inline-block !important;
&::before {
font-family: 'argon' !important;
font-style: normal !important;
font-weight: normal !important;
font-variant: normal !important;
text-transform: none !important;
line-height: 1;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
content: "\e20e";
font-size: 1.7rem;
}
}
// Brand visibility
header>.fill>.container>.flex1>.brand {
display: inline-block;
}
// Navigation font size
.main>.main-left>.nav>.slide>.slide-menu>li>a {
font-size: 0.875rem;
}
}
// 6. Mobile Portrait (600px and below)
// ==========================================================================
@media screen and (max-width: 600px) {
// Hide mobile elements
.mobile-hide {
display: none;
}
// Content margins
#maincontent>.container {
margin: 0 1rem 1rem 1rem;
}
// Form adjustments
.cbi-value-title {
text-align: left;
}
.cbi-dynlist p {
padding: 0.5rem 1rem;
}
// Prevent horizontal scroll
body {
overflow-x: hidden;
}
// Login page adjustments
.node-main-login .main .main-right #maincontent .container .cbi-map .cbi-section .cbi-section-node .cbi-value .cbi-value-field {
width: 16rem;
}
.node-main-login footer {
display: none;
}
// Tab scrollbar hiding
.tabs,
.cbi-tabmenu {
&::-webkit-scrollbar {
width: 0px;
height: 0px;
}
}
// Form field layout
.cbi-value-field,
.cbi-value-description {
display: block !important;
padding-left: 0 !important;
padding-right: 0 !important;
}
[data-page="admin-system-admin-password"] .cbi-value-field {
display: table-cell !important;
}
// Modal adjustments
.modal.cbi-modal {
max-width: 100%;
max-height: none;
}
.modal {
display: flex;
align-items: center;
flex-wrap: wrap;
width: 100%;
min-width: 270px;
max-width: 600px;
min-height: 32px;
margin: 5em auto;
padding: 1em;
border-radius: 3px !important;
background: #fff;
box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.16), 0 0 2px 0 rgba(0, 0, 0, 0.12);
}
// Dropdown adjustments
.cbi-dropdown[open]>ul.dropdown {
margin-bottom: 1rem;
}
// Login page footer
.login-page .login-container footer {
display: none;
}
}
// 7. Small Mobile (480px and below)
// ==========================================================================
@media screen and (max-width: 480px) {
// Hide mobile elements
.mobile-hide {
display: none;
}
// Interface box adjustments for overview page
div[style*="display:grid;grid-template-columns:repeat"] {
.ifacebox {
flex-basis: 80px;
.ifacebox-body {
padding: 0.875rem 0.5rem;
font-size: 0.6rem;
}
}
}
// Login page mobile adjustments
.login-page .login-container {
margin-left: 0rem !important;
width: 100%;
.login-form {
.form-login {
.input-group {
&::before {
color: #525461;
}
input {
color: #525461;
border-bottom: white 1px solid;
border-bottom: var(--white) 1px solid;
border-radius: 0;
}
}
}
}
}
}
// 8. Desktop Scrollbar Styling (600px and above)
// ==========================================================================
@media screen and (min-width: 600px) {
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar,
::-webkit-scrollbar-corner {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--primary);
border-radius: 10px;
&:hover {
background: var(--primary);
}
&:active {
background: var(--primary);
}
}
}
@@ -0,0 +1,257 @@
// out: false
// ==========================================================================
// Login Page Styles
// Optimized with LESS best practices
// ==========================================================================
// Mixins for common patterns
.flex-center() {
display: flex;
align-items: center;
justify-content: center;
}
.absolute-full() {
position: absolute;
top: 0;
left: 0;
height: 100vh;
width: 100vw;
}
.icon-base() {
font-family: 'argon' !important;
font-style: normal;
font-weight: normal;
font-variant: normal;
text-transform: none;
line-height: 1;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.login-page {
display: flex;
height: 100%;
flex-direction: column;
// Background video container
.video {
.absolute-full();
.flex-center();
background-color: var(--darker);
overflow: hidden;
video {
width: 100%;
height: auto;
}
}
// Volume control button
.volume-control {
position: fixed;
top: 1rem;
right: 1rem;
width: 1.5rem;
height: 1.5rem;
z-index: 5000;
cursor: pointer;
background: url(../img/volume_high.svg) no-repeat center/contain;
&.mute {
background-image: url(../img/volume_off.svg);
}
}
// Main background image
.main-bg {
.absolute-full();
background: url(../img/blank.png) no-repeat center/cover;
transition: all 0.5s ease;
}
// Login container
.login-container {
z-index: 10;
margin-left: 5%;
display: flex;
height: 100vh;
width: 26rem;
flex-direction: column;
background-color: var(--white);
backdrop-filter: blur(var(--blur-radius));
background-color: rgba(244, 245, 247, var(--blur-opacity));
box-shadow: rgba(0, 0, 0, 0.75) 0 0 35px -5px;
// Login form
.login-form {
.absolute-full();
display: flex;
flex-direction: column;
align-items: center;
max-width: 26rem;
// Brand/Logo section
.brand {
display: flex;
align-items: center;
justify-content: center;
margin: 50px auto 100px 50px;
color: var(--default);
text-decoration: none;
.icon {
width: 50px;
height: auto;
margin-right: 25px;
}
.brand-text {
margin-right: 45px;
font-size: 1.25rem;
font-weight: 700;
font-family: "TypoGraphica";
word-break: break-word;
}
&:hover {
text-decoration: none;
}
}
// Form container
.form-login {
width: 100%;
padding: 20px 50px;
box-sizing: border-box;
.errorbox {
padding-bottom: 2rem;
text-align: center;
color: var(--warning);
}
// Input group styling
.input-group {
position: relative;
margin-bottom: 1.25rem;
// Icon styling
&::before {
.icon-base();
position: absolute;
top: 10px;
left: 10px;
z-index: 100;
font-size: 1.5rem;
color: var(--default);
}
// Animated border
.border {
position: absolute;
bottom: 0;
width: 100%;
height: 1px;
border-bottom: 1px solid var(--primary);
transform: scaleX(0);
transition: transform 0.3s ease;
}
// Input field
input {
display: block;
width: 100%;
margin: 0.825rem 0;
padding: 0.5rem 0.75rem 0.5rem 3rem;
font-size: 1rem;
line-height: 1.5em;
color: var(--default);
background-color: transparent;
background-clip: padding-box;
border: 0;
border-bottom: 1px solid var(--white);
border-radius: 0;
outline: none;
box-sizing: border-box;
box-shadow: 0 3px 2px rgba(233, 236, 239, 0.05);
transition: all 0.3s cubic-bezier(0.68, -0.55, 0.265, 1.55);
&:focus+.border {
transform: scaleX(1);
}
}
.cbi-input-password {
position: relative;
margin-bottom: 2rem;
}
// Icon content
&.user-icon::before {
content: "\e971";
}
&.pass-icon::before {
content: "\e910";
}
}
}
// Submit button
.cbi-button-apply {
width: 100% !important;
min-height: 50px;
margin: 30px 0 100px;
padding: 10px 0;
font-size: 15px;
font-weight: 600;
color: var(--white);
text-align: center;
letter-spacing: 0.8rem;
background-color: var(--primary) !important;
border: none;
border-radius: 6px;
outline: none;
cursor: pointer;
box-shadow: rgba(0, 0, 0, 0.1) 0 0 50px 0;
transition: all 0.3s ease !important;
&:hover,
&:focus {
opacity: 0.9;
}
}
}
// Footer
footer {
position: absolute;
bottom: 0;
z-index: 10;
display: flex;
justify-content: space-evenly;
width: 100%;
margin-top: auto;
padding: 0 0 30px;
color: var(--gray);
text-align: center;
line-height: 1.6rem;
font-size: 0.75rem;
.ftc {
position: absolute;
bottom: 30px;
width: 100%;
}
.luci-link {
display: block;
}
}
}
}
@@ -0,0 +1,12 @@
#!/bin/sh
if [ "$PKG_UPGRADE" != 1 ]; then
uci get luci.themes.Argon >/dev/null 2>&1 || \
uci batch <<-EOF
set luci.themes.Argon=/luci-static/argon
set luci.main.mediaurlbase=/luci-static/argon
commit luci
EOF
fi
exit 0
@@ -0,0 +1,108 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-3.0-only
. /usr/share/libubox/jshn.sh
src="$(uci -q get argon.@global[0].online_wallpaper || echo bing)"
case "$src" in
bing|none|ghser|unsplash|wallhaven) WEB_PIC_SRC="$src" ;;
unsplash_[0-9]*|wallhaven_[0-9]*) WEB_PIC_SRC="$src" ;;
*) WEB_PIC_SRC="bing" ;;
esac
API_KEY="$(uci -q get argon.@global[0].use_api_key)"
EXACT_RESO="$(uci -q get argon.@global[0].use_exact_resolution || echo '1')"
case "$API_KEY" in *[!A-Za-z0-9_.-]*|????????????????????????????????????????????????????????????????*) API_KEY="" ;; esac
case "$EXACT_RESO" in 1) ;; *) EXACT_RESO=0 ;; esac
cache_key="$(echo "$WEB_PIC_SRC" | tr -c 'A-Za-z0-9_-' '_')"
CACHE="/var/run/argon_${cache_key}.url"
WRLOCK="/var/lock/argon_${cache_key}.lock"
valid_url() {
case "$1" in
https://www.bing.com/*|//www.bing.com/*|https://api.vvhan.com/*|https://images.unsplash.com/*|https://wallhaven.cc/*) return 0 ;;
esac
return 1
}
fetch_pic_url() {
case "$WEB_PIC_SRC" in
bing)
local picpath="$(wget -T3 -qO- "https://www.bing.com/HPImageArchive.aspx?format=js&n=1" | jsonfilter -qe '@.images[0].url' | sed 's/1920x1080/UHD/g')"
[ -n "$picpath" ] && echo "//www.bing.com${picpath}"
;;
ghser)
echo "https://api.vvhan.com/api/wallpaper/acg"
;;
unsplash)
if [ -z "$API_KEY" ]; then
local pic_id="$(wget -T3 --spider "https://source.unsplash.com/1920x1080/daily?wallpapers" 2>&1 | grep -Eo "photo-\w+-\w+" | head -n1)"
[ -n "$pic_id" ] && echo "https://images.unsplash.com/${pic_id}?fm=jpg&fit=crop&w=1920&h=1080"
else
wget -T3 -qO- "https://api.unsplash.com/photos/random?client_id=${API_KEY}" | jsonfilter -qe '@["urls"]["regular"]'
fi
;;
unsplash_*)
local collection_id="${WEB_PIC_SRC#unsplash_}"
if [ -z "$API_KEY" ]; then
local pic_id="$(wget -T3 --spider "https://source.unsplash.com/collection/${collection_id}/1920x1080" 2>&1 | grep -Eo "photo-\w+-\w+" | head -n1)"
[ -n "$pic_id" ] && echo "https://images.unsplash.com/${pic_id}?fm=jpg&fit=crop&w=1920&h=1080"
else
wget -T3 -qO- "https://api.unsplash.com/photos/random?client_id=${API_KEY}&collections=${collection_id}" | jsonfilter -qe '@["urls"]["regular"]'
fi
;;
wallhaven)
wget -T3 -qO- "https://wallhaven.cc/api/v1/search?resolutions=1920x1080&sorting=random" | jsonfilter -qe '@.data[0].path'
;;
wallhaven_*)
local tag_id="${WEB_PIC_SRC#wallhaven_}"
local use_reso="resolutions"
[ "$EXACT_RESO" -eq 1 ] || use_reso="atleast"
[ -z "$API_KEY" ] || API_KEY="apikey=$API_KEY&"
wget -T3 -qO- "https://wallhaven.cc/api/v1/search?${API_KEY}q=id%3A${tag_id}&${use_reso}=1920x1080&sorting=random" | jsonfilter -qe '@.data[0].path'
;;
esac
}
try_update() {
exec 200>"$WRLOCK"
if flock -n 200 >/dev/null 2>&1; then
local picurl="$(fetch_pic_url)"
if valid_url "$picurl"; then
printf '%s\n' "$picurl" > "$CACHE"
printf '%s\n' "$picurl"
elif [ -s "$CACHE" ]; then
cat "$CACHE"
else
: > "$CACHE"
fi
flock -u 200 >/dev/null 2>&1
elif [ -s "$CACHE" ]; then
cat "$CACHE"
fi
}
case "$1" in
"list")
json_init; json_add_object "get_url"; json_close_object; json_dump; json_cleanup
;;
"call")
case "$2" in
"get_url")
read -r input
if [ -f "$CACHE" ]; then
idle_t="$(($(date '+%s') - $(date -r "$CACHE" '+%s' 2>/dev/null || echo '0')))"
if [ -s "$CACHE" ] && [ "$idle_t" -le 43200 ]; then
url="$(cat "$CACHE")"
valid_url "$url" || url=""
json_init; json_add_string "url" "$url"; json_dump; json_cleanup; return 0
elif [ ! -s "$CACHE" ] && [ "$idle_t" -le 120 ]; then
echo '{ "url": "" }'; return 1
fi
fi
json_init; json_add_string "url" "$(try_update)"; json_dump; json_cleanup; return 0
;;
esac
;;
esac
@@ -0,0 +1,8 @@
{
"luci-theme-argon": {
"description": "Grant UCI access for luci-theme-argon",
"read": {
"uci": [ "argon" ]
}
}
}
@@ -0,0 +1,55 @@
{#
Argon is a clean HTML5 theme for LuCI. It is based on luci-theme-material Argon Template
luci-theme-argon
Copyright 2020 Jerrykuku <jerrykuku@qq.com>
Have a bug? Please create an issue here on GitHub!
https://github.com/jerrykuku/luci-theme-argon/issues
luci-theme-material:
Copyright 2015 Lutty Yang <lutty@wcan.in>
Agron Theme
https://demos.creative-tim.com/argon-dashboard/index.html
Licensed to the public under the Apache License 2.0
-#}
</div>
<footer class="mobile-hide" style="text-wrap: auto">
<div class="footer-content" style="display: flex; flex-wrap: wrap; gap: 0.5em; justify-content: end;">
<a class="luci-link" href="https://github.com/openwrt/luci" target="_blank">Powered by {{ version.luciname }} ({{ version.luciversion }})</a>
<span class="footer-separator">|</span>
<a href="https://github.com/jerrykuku/luci-theme-argon" target="_blank">ArgonTheme {# vPKG_VERSION #}</a>
<span class="footer-separator">|</span>
<a class="luci-link" href="{{ version.disturl }}" target="_blank">{{ version.distname }} {{ version.distversion }}-{{ version.distrevision }}</a>
<ul class="breadcrumb pull-right" id="modemenu" style="display:none"></ul>
</div>
</footer>
</div>
</div>
<script>
// thanks for Jo-Philipp Wich <jow@openwrt.org>
var luciLocation = {{ ctx.path }};
var winHeight = window.innerHeight;
window.addEventListener('resize', function () {
var winWidth = window.innerWidth;
if(winWidth < 600){
var newHeight = window.innerHeight;
var keyboradHeight = newHeight - winHeight;
var ftcElement = document.querySelector(".ftc");
if (ftcElement) {
ftcElement.style.bottom = (keyboradHeight + 30) + "px";
}
}
});
</script>
<script type="text/javascript">L.require('menu-argon')</script>
</body>
</html>
@@ -0,0 +1,47 @@
{#
Argon is a clean HTML5 theme for LuCI. It is based on luci-theme-material Argon Template
luci-theme-argon
Copyright 2020 Jerrykuku <jerrykuku@qq.com>
Have a bug? Please create an issue here on GitHub!
https://github.com/jerrykuku/luci-theme-argon/issues
luci-theme-material:
Copyright 2015 Lutty Yang <lutty@wcan.in>
Agron Theme
https://demos.creative-tim.com/argon-dashboard/index.html
Licensed to the public under the Apache License 2.0
-#}
</div>
<!-- added style="text-wrap: auto" but on this page it is still not the best solution. I will take it to consideration next time :) -->
<footer style="text-wrap: auto">
<div>
<a class="luci-link" href="https://github.com/openwrt/luci" target="_blank">Powered by {{ version.luciname }} ({{ version.luciversion }})</a>
<a href="https://github.com/jerrykuku/luci-theme-argon" target="_blank">ArgonTheme {# vPKG_VERSION #}</a>
<a class="luci-link" href="{{ version.disturl }}" target="_blank">{{ version.distname }} {{ version.distversion }}-{{ version.distrevision }}</a>
</div>
</footer>
</div>
</div>
<script>
// thanks for Jo-Philipp Wich <jow@openwrt.org>
var luciLocation = {{ ctx.path }};
var winHeight = window.innerHeight;
window.addEventListener('resize', function () {
var winWidth = window.innerWidth;
if(winWidth < 600){
var newHeight = window.innerHeight;
var keyboradHeight = newHeight - winHeight;
var ftcElement = document.querySelector(".ftc");
if (ftcElement) {
ftcElement.style.bottom = (keyboradHeight + 30) + "px";
}
}
});
</script>
</body>
</html>
@@ -0,0 +1,181 @@
{#
Argon is a clean HTML5 theme for LuCI. It is based on luci-theme-material Argon Template
luci-theme-argon
Copyright 2020 Jerrykuku <jerrykuku@qq.com>
Have a bug? Please create an issue here on GitHub!
https://github.com/jerrykuku/luci-theme-argon/issues
luci-theme-material:
Copyright 2015 Lutty Yang <lutty@wcan.in>
Argon Theme
https://demos.creative-tim.com/argon-dashboard/index.html
Licensed to the public under the Apache License 2.0
-#}
{%
import { readfile, access } from 'fs';
import { cursor } from 'uci';
import { srand } from 'math';
import { getuid, getspnam } from 'luci.core';
const boardinfo = ubus.call("system", "board");
const hostname = striptags(boardinfo?.hostname ?? '?');
let cfg = cursor();
//send as HTML5
http.prepare_content('text/html; charset=UTF-8');
srand(+substr(reverse(time() + ""), 0, 8));
//Custom settings
let mode = 'normal';
let bar_color = '#5e72e4';
let primary, dark_primary, blur_radius, blur_radius_dark, blur_opacity, blur_opacity_dark;
const dark_css = readfile('/www/luci-static/argon/css/dark.css');
if (access('/etc/config/argon')) {
primary = cfg.get_first('argon', 'global', 'primary');
dark_primary = cfg.get_first('argon', 'global', 'dark_primary');
blur_radius = cfg.get_first('argon', 'global', 'blur');
blur_radius_dark = cfg.get_first('argon', 'global', 'blur_dark');
blur_opacity = cfg.get_first('argon', 'global', 'transparency');
blur_opacity_dark = cfg.get_first('argon', 'global', 'transparency_dark');
mode = cfg.get_first('argon', 'global', 'mode');
bar_color = (mode == 'dark') ? dark_primary : primary;
}
-%}
<!DOCTYPE html>
<html lang="{{ dispatcher.lang }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>{{ hostname }}{{ node?.title ? ` - ${striptags(node.title)}` : '' }} - LuCI</title>
<!-- SEO and Description -->
<meta name="robots" content="noindex, nofollow">
<!-- Security -->
<meta http-equiv="X-Content-Type-Options" content="nosniff">
<meta http-equiv="X-Frame-Options" content="SAMEORIGIN">
<meta http-equiv="X-XSS-Protection" content="1; mode=block">
<meta http-equiv="Referrer-Policy" content="strict-origin-when-cross-origin">
<!-- Mobile and PWA -->
<meta name="format-detection" content="telephone=no, email=no">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="{{ hostname }} - LuCI">
<!-- Theme and Colors -->
<meta name="theme-color" content="{{ bar_color }}">
<meta name="msapplication-TileColor" content="{{ bar_color }}">
<meta name="msapplication-TileImage" content="{{ media }}/icon/ms-icon-144x144.png">
<meta name="application-name" content="{{ hostname }} - LuCI">
<!-- Icons and Manifest -->
<link rel="icon" type="image/x-icon" href="{{ media }}/favicon.ico">
<link rel="icon" type="image/png" sizes="16x16" href="{{ media }}/icon/favicon-16x16.png">
<link rel="icon" type="image/png" sizes="32x32" href="{{ media }}/icon/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="96x96" href="{{ media }}/icon/favicon-96x96.png">
<link rel="icon" type="image/png" sizes="192x192" href="{{ media }}/icon/android-icon-192x192.png">
<link rel="apple-touch-icon" sizes="60x60" href="{{ media }}/icon/apple-icon-60x60.png">
<link rel="apple-touch-icon" sizes="72x72" href="{{ media }}/icon/apple-icon-72x72.png">
<link rel="apple-touch-icon" sizes="144x144" href="{{ media }}/icon/apple-icon-144x144.png">
<link rel="manifest" href="{{ media }}/icon/manifest.json" crossorigin="use-credentials">
<link rel="stylesheet" href="{{ media }}/css/cascade.css{# ?v=PKG_VERSION #}">
<style title="text/css">
{% if (mode == 'normal'): %}
@media (prefers-color-scheme: dark) {
{{ dark_css }}
}
{% endif %}
{% if (mode == 'dark'): %}
{{ dark_css }}
{% endif -%}
{% if (access('/etc/config/argon')): %}
:root {
--primary: {{ primary }};
--dark-primary: {{ dark_primary }};
--blur-radius:{{ blur_radius }}px;
--blur-opacity:{{ blur_opacity }};
--blur-radius-dark:{{ blur_radius_dark }}px;
--blur-opacity-dark:{{ blur_opacity_dark }};
}
{% endif -%}
</style>
<!-- Stylesheets -->
{% if (node?.css): %}
<link rel="stylesheet" href="{{ resource }}/{{ node.css}}">
{% endif -%}
{% if (css): %}
<style title="text/css">
{{ css }}
</style>
{% endif -%}
<script src="{{ dispatcher.build_url('admin/translations', dispatcher.lang) }}?v={{ version.luciversion }}"></script>
<script src="{{ resource }}/cbi.js?v={{ version.luciversion }}"></script>
<script src="{{ resource }}/luci.js?v={{ version.luciversion }}"></script>
</head>
<body
class="lang_{{ dispatcher.lang }} {{ node?.title ? ` - ${striptags(node.title)}` : '' }} {% if (ctx.authsession): %}logged-in{% endif %}"
data-page="{{ entityencode(join('-', ctx.request_path), true) }}">
<div class="main">
<div class="main-left" id="mainmenu" style="display:none">
<div class="sidenav-header d-flex align-items-center">
<a class="brand" href="#">{{ hostname }}</a>
<div class="ml-auto">
<!-- Sidenav toggler -->
<div class="sidenav-toggler d-none d-xl-block active" data-action="sidenav-unpin"
data-target="#sidenav-main">
<div class="sidenav-toggler-inner">
<i class="sidenav-toggler-line"></i>
<i class="sidenav-toggler-line"></i>
<i class="sidenav-toggler-line"></i>
</div>
</div>
</div>
</div>
</div>
<div class="main-right">
<header class="bg-primary">
<div class="fill">
<div class="container">
<div class="flex1">
<a class="showSide"></a>
<a class="brand" href="#">{{ hostname }}</a>
</div>
<div class="status" id="indicators"></div>
</div>
</div>
</header>
<div class="darkMask"></div>
<div id="maincontent">
<div class="container">
{% if (getuid() == 0 && getspnam('root')?.pwdp === ''): %}
<div class="alert-message error">
<h4>{{ _('No password set!') }}</h4>
<p>{{ _('There is no password set on this router. Please configure a root password to protect the web interface.') }}</p>
{% if (dispatcher.lookup("admin/system/admin")): %}
<div class="right"><a class="btn" href="{{ dispatcher.build_url("admin/system/admin") }}">{{ _('Go to password configuration...') }}</a></div>
{% endif %}
</div>
{% endif %}
<noscript>
<div class="alert-message error">
<h4>{{ _('JavaScript required!') }}</h4>
<p>{{ _('You must enable JavaScript in your browser or LuCI will not work properly.') }}</p>
</div>
</noscript>
<div id="tabmenu" style="display:none"></div>
@@ -0,0 +1,125 @@
{#
Argon is a clean HTML5 theme for LuCI. It is based on luci-theme-material Argon Template
luci-theme-argon
Copyright 2020 Jerrykuku <jerrykuku@qq.com>
Have a bug? Please create an issue here on GitHub!
https://github.com/jerrykuku/luci-theme-argon/issues
luci-theme-material:
Copyright 2015 Lutty Yang <lutty@wcan.in>
Argon Theme
https://demos.creative-tim.com/argon-dashboard/index.html
Licensed to the public under the Apache License 2.0
-#}
{%
import { readfile, access } from 'fs';
import { cursor } from 'uci';
import { srand } from 'math';
const boardinfo = ubus.call("system", "board");
const hostname = striptags(boardinfo?.hostname ?? '?');
let cfg = cursor();
//send as HTML5
http.prepare_content('text/html; charset=UTF-8');
srand(+substr(reverse(time() + ""), 0, 8));
//Custom settings
let mode = 'normal';
let bar_color = '#5e72e4';
let primary, dark_primary, blur_radius, blur_radius_dark, blur_opacity, blur_opacity_dark;
const dark_css = readfile('/www/luci-static/argon/css/dark.css');
if (access('/etc/config/argon')) {
primary = cfg.get_first('argon', 'global', 'primary');
dark_primary = cfg.get_first('argon', 'global', 'dark_primary');
blur_radius = cfg.get_first('argon', 'global', 'blur');
blur_radius_dark = cfg.get_first('argon', 'global', 'blur_dark');
blur_opacity = cfg.get_first('argon', 'global', 'transparency');
blur_opacity_dark = cfg.get_first('argon', 'global', 'transparency_dark');
mode = cfg.get_first('argon', 'global', 'mode');
bar_color = (mode == 'dark') ? dark_primary : primary;
}
-%}
<!DOCTYPE html>
<html lang="{{ dispatcher.lang }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>{{ hostname }}{{ node?.title ? ` - ${striptags(node.title)}` : '' }} - LuCI</title>
<!-- SEO and Description -->
<meta name="robots" content="noindex, nofollow">
<!-- Security -->
<meta http-equiv="X-Content-Type-Options" content="nosniff">
<meta http-equiv="X-Frame-Options" content="SAMEORIGIN">
<meta http-equiv="X-XSS-Protection" content="1; mode=block">
<meta http-equiv="Referrer-Policy" content="strict-origin-when-cross-origin">
<!-- Mobile and PWA -->
<meta name="format-detection" content="telephone=no, email=no">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="{{ hostname }} - LuCI">
<!-- Theme and Colors -->
<meta name="theme-color" content="{{ bar_color }}">
<meta name="msapplication-TileColor" content="{{ bar_color }}">
<meta name="msapplication-TileImage" content="{{ media }}/icon/ms-icon-144x144.png">
<meta name="application-name" content="{{ hostname }} - LuCI">
<!-- Icons and Manifest -->
<link rel="icon" type="image/x-icon" href="{{ media }}/favicon.ico">
<link rel="icon" type="image/png" sizes="16x16" href="{{ media }}/icon/favicon-16x16.png">
<link rel="icon" type="image/png" sizes="32x32" href="{{ media }}/icon/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="96x96" href="{{ media }}/icon/favicon-96x96.png">
<link rel="icon" type="image/png" sizes="192x192" href="{{ media }}/icon/android-icon-192x192.png">
<link rel="apple-touch-icon" sizes="60x60" href="{{ media }}/icon/apple-icon-60x60.png">
<link rel="apple-touch-icon" sizes="72x72" href="{{ media }}/icon/apple-icon-72x72.png">
<link rel="apple-touch-icon" sizes="144x144" href="{{ media }}/icon/apple-icon-144x144.png">
<link rel="manifest" href="{{ media }}/icon/manifest.json" crossorigin="use-credentials">
<link rel="stylesheet" href="{{ media }}/css/cascade.css{# ?v=PKG_VERSION #}">
<style title="text/css">
{% if (mode == 'normal'): %}
@media (prefers-color-scheme: dark) {
{{ dark_css }}
}
{% endif %}
{% if (mode == 'dark'): %}
{{ dark_css }}
{% endif -%}
{% if (access('/etc/config/argon')): %}
:root {
--primary: {{ primary }};
--dark-primary: {{ dark_primary }};
--blur-radius:{{ blur_radius }}px;
--blur-opacity:{{ blur_opacity }};
--blur-radius-dark:{{ blur_radius_dark }}px;
--blur-opacity-dark:{{ blur_opacity_dark }};
}
{% endif -%}
</style>
<link rel="shortcut icon" href="{{ media }}/favicon.ico">
{% if (node?.css): %}
<link rel="stylesheet" href="{{ resource }}/{{ node.css}}">
{% endif -%}
{% if (css): %}
<style title="text/css">
{{ css }}
</style>
{% endif -%}
</head>
<body>
@@ -0,0 +1,12 @@
{#
Copyright 2008 Steven Barth <steven@midlink.org>
Copyright 2008-2019 Jo-Philipp Wich <jo@mein.io>
Licensed to the public under the Apache License 2.0.
-#}
{%
if (!ctx.template_header_sent) {
include("themes/" + theme + "/header_login");
ctx.template_header_sent = true;
}
%}
@@ -0,0 +1,165 @@
{#
Argon is a clean HTML5 theme for LuCI. It is based on luci-theme-bootstrap and MUI and Argon Template
luci-theme-argon
Copyright 2020 Jerryk <jerrykuku@gmail.com>
Have a bug? Please create an issue here on GitHub!
https://github.com/jerrykuku/luci-theme-argon/issues
luci-theme-bootstrap:
Copyright 2008 Steven Barth <steven@midlink.org>
Copyright 2008-2016 Jo-Philipp Wich <jow@openwrt.org>
Copyright 2012 David Menting <david@nut-bolt.nl>
MUI:
https://github.com/muicss/mui
Argon Theme
https://demos.creative-tim.com/argon-dashboard/index.html
Licensed to the public under the Apache License 2.0
-#}
{% include("themes/" + theme + "/out_header_login") %}
{%
import * as fs from 'fs';
import { cursor } from 'uci';
import { rand } from 'math';
let cfg = cursor();
//Fetch Local Background Media
const imageTypes = " jpg jpeg png gif webp ";
const videoTypes = " mp4 webm ";
const allTypes = imageTypes + videoTypes;
function fetchMedia(path, themeDir) {
let backgroundTable = [];
for (f in (fs.lsdir(path))) {
let ext = lc(split(f, '.')?.[1]);
if (ext && index(allTypes, " " + ext + " ") != -1) {
let bg = {};
bg.type = ext;
bg.url = themeDir + f;
push(backgroundTable, bg);
}
}
return backgroundTable;
}
function selectBackground(themeDir) {
let bgUrl = media + "/img/bg1.jpg";
let remoteBgUrl = "";
let backgroundType = "Image";
let mimeType = "";
if (fs.access("/etc/config/argon")) {
let online_wallpaper = cfg.get_first('argon', 'global', 'online_wallpaper') ?? (cfg.get_first('argon', 'global', 'bing_background') == '1' ? 'bing' : null);
if (online_wallpaper && online_wallpaper != "none") {
const picurl = ubus.call("luci.argon_wallpaper", "get_url") ?? {};
if (picurl?.url && match(picurl.url, /^(https?:\/\/|\/\/)www\.bing\.com\/|^(https?:\/\/)api\.vvhan\.com\/|^(https?:\/\/)images\.unsplash\.com\/|^(https?:\/\/)wallhaven\.cc\//))
remoteBgUrl = picurl.url;
}
}
let background = fetchMedia("/www" + themeDir, themeDir);
// Priority: local uploaded backgrounds win; only fall through to
// the remote Bing/preload URL when no local files exist.
if (length(background) > 0) {
let currentBg = background[(rand() % length(background))];
bgUrl = currentBg.url;
if (index(videoTypes, " " + currentBg.type + " ") != -1) {
backgroundType = "Video";
mimeType = "video/" + currentBg.type;
}
}
return {bgUrl, remoteBgUrl, backgroundType, mimeType};
}
const boardinfo = ubus.call("system", "board");
const hostname = striptags(boardinfo?.hostname ?? '?');
const themeDir = media + "/background/";
const bgArry = selectBackground(themeDir);
%}
<!-- Login Page Start -->
<div class="login-page">
{% if ( bgArry.backgroundType == "Video" ): %}
<!-- Video Player Start -->
<div class="video">
<video autoplay loop muted id="video">
<source src="{{ bgArry.bgUrl }}" type="{{ bgArry.mimeType }}">
</video>
</div>
<div class="volume-control mute"></div>
<script>
document.querySelector(".volume-control").addEventListener("click", function(){
if(this.classList.contains("mute")){
this.classList.remove("mute");
document.getElementById("video").muted = false;
}else{
this.classList.add("mute");
document.getElementById("video").muted = true;
}
});
</script>
<!-- Video Player End -->
{% else %}
<!-- Image Background Start -->
<div class="main-bg" id="main-bg" style="background-image:url({{ bgArry.bgUrl }})"></div>
<script type="text/javascript">//<![CDATA[
(function() {
var remote = "{{ bgArry.remoteBgUrl }}";
if (!remote)
return;
var img = new Image();
img.onload = function() {
var el = document.getElementById("main-bg");
if (el)
el.style.backgroundImage = "url(" + remote + ")";
};
img.onerror = function() {};
img.src = remote;
})();
//]]></script>
<!-- Image Background End -->
{% endif %}
<!-- Login Container Start -->
<div class="login-container">
<div class="login-form">
<!-- Logo Start -->
<a class="brand" href="/"><img src="{{ media }}/img/argon.svg" class="icon">
<span class="brand-text">{{ hostname }}</span>
</a>
<!-- Logo End -->
<!-- Login Form Start -->
<form class="form-login" method="post" action="{{ http.getenv("REQUEST_URI") }}">
{%- if (fuser): %}
<div class="errorbox">{{ _('Invalid username and/or password! Please try again.') }}</div>
{% endif -%}
<div class="input-container">
<div class="input-group user-icon">
<input class="cbi-input-user" id="cbi-input-user" type="text" name="luci_username" value="{{ entityencode(duser, true) }}" />
<label class="border" for="cbi-input-user"></label>
</div>
<div class="input-group pass-icon">
<input class="cbi-input-password" id="cbi-input-password" type="password" name="luci_password" />
<label class="border" for="cbi-input-password"></label>
</div>
</div>
<div>
<input type="submit" value="{{ _('Log in') }}" class="cbi-button cbi-button-apply" />
</div>
</form>
<!-- Login Form End -->
<script type="text/javascript">//<![CDATA[
var input = document.getElementsByName('luci_password')[0];
if (input)
input.focus();
//]]></script>
{% include("themes/" + theme + "/footer_login") %}
@@ -0,0 +1,188 @@
#!/usr/bin/env python3
"""Static safety gates for the v46.1 UI-only LuCI packages."""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent
EXPECTED_PACKAGES = {
"luci-theme-argon",
"luci-app-argon-config",
"luci-app-wgtunnel",
"luci-app-tr3000-status",
}
FORBIDDEN_PACKAGES = {
"luci-app-turboacc-mtk",
"luci-app-wrtbwmon",
"kmod-mediatek_hnat",
"kmod-warp",
"kmod-mt_wifi",
"kmod-tcp-bbr",
}
GLOBAL_FORBIDDEN = {
"ifup lan": "must never bounce the management LAN",
"wifi restart": "must never reset Wi-Fi from a UI package",
"/etc/init.d/network restart": "must never restart the global network",
"/etc/init.d/firewall restart": "must never restart fw4",
}
FRONTEND_FORBIDDEN = {
"handleSaveApply(": "WG UI must not invoke LuCI global apply",
"fs.exec": "browser code must not execute programs",
"uci.load('network')": "WG UI must not receive generic network UCI access",
'uci.load("network")': "WG UI must not receive generic network UCI access",
"new form.Map('network'": "WG UI must use JSONMap instead of network UCI",
'new form.Map("network"': "WG UI must use JSONMap instead of network UCI",
}
KEY_LIKE = re.compile(r"(?<![A-Za-z0-9+/])[A-Za-z0-9+/]{43}=(?![A-Za-z0-9+/=])")
def fail(message: str) -> None:
print(f"FAIL: {message}")
raise SystemExit(1)
def text_files(package: Path):
for path in sorted(package.rglob("*")):
if not path.is_file():
continue
# Repo-only documentation (not shipped in the IPK) may legitimately
# name forbidden patterns when describing them; only scan shipped/code
# files for the forbidden-content gates.
if path.suffix == ".md" or path.name.upper().startswith("README"):
continue
# Test harnesses (not shipped) legitimately reference forbidden package
# names when asserting they are absent; skip them too.
if "tests" in path.relative_to(package).parts:
continue
try:
yield path, path.read_text(encoding="utf-8")
except UnicodeDecodeError:
continue
def load_json(path: Path) -> dict:
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
fail(f"invalid JSON {path.relative_to(ROOT)}: {exc}")
def sole_acl(path: Path, name: str) -> dict:
data = load_json(path)
if set(data) != {name}:
fail(f"{path.relative_to(ROOT)} must contain only ACL {name}")
return data[name]
def assert_ubus_only(block: dict, obj: str, methods: set[str]) -> None:
if set(block) != {"ubus"}:
fail(f"ACL block for {obj} must grant ubus only, got {sorted(block)}")
ubus = block["ubus"]
if set(ubus) != {obj} or set(ubus[obj]) != methods:
fail(f"ACL for {obj} expected methods {sorted(methods)}, got {ubus}")
def check_wgtunnel() -> None:
package = ROOT / "luci-app-wgtunnel"
acl = sole_acl(
package / "root/usr/share/rpcd/acl.d/luci-app-wgtunnel.json",
"luci-app-wgtunnel",
)
assert_ubus_only(acl.get("read", {}), "luci.wgtunnel", {"get", "status"})
assert_ubus_only(
acl.get("write", {}),
"luci.wgtunnel",
{"prepare", "apply", "rollback", "reconnect"},
)
frontend = package / "htdocs/luci-static/resources/view/wgtunnel.js"
source = frontend.read_text(encoding="utf-8")
for needle, reason in FRONTEND_FORBIDDEN.items():
if needle in source:
fail(f"{frontend.relative_to(ROOT)} contains {needle!r}: {reason}")
for required in ("form.JSONMap", "luci.wgtunnel", "prepare", "apply"):
if required not in source:
fail(f"{frontend.relative_to(ROOT)} lacks {required!r}")
backend = package / "root/usr/share/rpcd/ucode/luci.wgtunnel"
if not backend.is_file():
fail(f"missing {backend.relative_to(ROOT)}")
source = backend.read_text(encoding="utf-8")
for method in ("get", "status", "prepare", "apply", "rollback", "reconnect"):
if re.search(rf"\b{re.escape(method)}\s*:", source) is None:
fail(f"WG backend lacks method {method}")
for required in ("10.99.0.2/32", "10.99.0.1/32", "wg0", "vxlan0"):
if required not in source:
fail(f"WG backend lacks topology fuse {required}")
def check_status() -> None:
package = ROOT / "luci-app-tr3000-status"
acl = sole_acl(
package / "root/usr/share/rpcd/acl.d/luci-app-tr3000-status.json",
"luci-app-tr3000-status",
)
assert_ubus_only(acl.get("read", {}), "luci.tr3000_status", {"get"})
if "write" in acl:
fail("Link Health ACL must not contain a write block")
backend = package / "root/usr/share/rpcd/ucode/luci.tr3000_status"
frontend = package / "htdocs/luci-static/resources/view/status/tr3000.js"
if not backend.is_file() or not frontend.is_file():
fail("Link Health backend/frontend is missing")
for path in (backend, frontend):
source = path.read_text(encoding="utf-8")
for needle in ("private_key", "preshared_key", "wifi-key", "wireless key"):
if needle in source.lower():
fail(f"{path.relative_to(ROOT)} references secret field {needle!r}")
def check_argon_acl() -> None:
path = ROOT / "luci-app-argon-config/root/usr/share/rpcd/acl.d/luci-app-argon-config.json"
acl = sole_acl(path, "luci-app-argon-config")
read_ubus = acl.get("read", {}).get("ubus", {})
write_ubus = acl.get("write", {}).get("ubus", {})
mutators = {"remove", "rename"}
if mutators & set(read_ubus.get("luci.argon", [])):
fail("Argon remove/rename methods must not be granted in read ACL")
if not mutators <= set(write_ubus.get("luci.argon", [])):
fail("Argon write ACL must grant remove and rename")
def check_all_sources() -> None:
packages = {path.name for path in ROOT.iterdir() if path.is_dir() and path.name.startswith("luci-")}
missing = EXPECTED_PACKAGES - packages
if missing:
fail(f"missing package directories: {sorted(missing)}")
for package_name in sorted(EXPECTED_PACKAGES):
package = ROOT / package_name
for path, source in text_files(package):
rel = path.relative_to(ROOT)
for needle, reason in GLOBAL_FORBIDDEN.items():
if needle in source:
fail(f"{rel} contains {needle!r}: {reason}")
for forbidden in FORBIDDEN_PACKAGES:
if forbidden in source:
fail(f"{rel} pulls forbidden package {forbidden}")
if KEY_LIKE.search(source):
fail(f"{rel} contains a possible WireGuard key literal")
if path.suffix == ".json":
load_json(path)
def main() -> int:
check_all_sources()
check_argon_acl()
check_wgtunnel()
check_status()
print("PASS: v46.1 UI-only static safety gates")
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,86 @@
#!/bin/sh
# v46.1 UI-only live install helper
# - Only installs the 5 audited IPKs (NO opkg update, NO network/firewall/wifi changes)
# - Pre-checks live immutable hashes, then installs, then post-checks
# - All operations are idempotent; safe to re-run after a partial failure
set -eu
BASE=/tmp/v46.1-ui
mkdir -p "$BASE"
cd "$BASE"
PKGS="
luci-theme-argon_2.4.3-r20250722_all.ipk
luci-app-argon-config_26.187.07912~668cdc6_all.ipk
luci-i18n-argon-config-zh-cn_26.187.07912~668cdc6_all.ipk
luci-app-wgtunnel_0_all.ipk
luci-app-tr3000-status_0_all.ipk
"
IMMU="
/etc/config/network
/etc/config/firewall
/etc/config/dhcp
/etc/config/wireless
/etc/rc.local
/etc/hotplug.d/iface/20-vxlan
/etc/hotplug.d/iface/30-mss-clamp
/usr/share/nftables.d/chain-pre/mangle_forward/30-mss-clamp.nft
"
sha256() { sha256sum "$1" 2>/dev/null | awk '{print $1}'; }
echo "=== pre-install snapshot ==="
{
for f in $IMMU; do
s=$(sha256 "$f" 2>/dev/null) || s=missing
printf "%s %s\n" "$s" "$f"
done
} > pre.sha256
cat pre.sha256
missing=""
for p in $PKGS; do
if [ ! -f "$BASE/$p" ]; then
missing="$missing $p"
fi
done
if [ -n "$missing" ]; then
echo "FAIL: missing IPK files in $BASE:$missing"
exit 1
fi
for p in $PKGS; do
echo "=== opkg install --force-reinstall $p ==="
opkg install --force-reinstall --noaction "$p" || true
done
for p in $PKGS; do
opkg install --force-reinstall "$p"
done
# Keep the active theme on Bootstrap until admin chooses Argon.
# To activate Argon:
# uci set luci.main.mediaurlbase=/luci-static/argon
# uci commit luci
# /etc/init.d/rpcd restart; /etc/init.d/uhttpd restart
rm -rf /tmp/luci-cache /tmp/luci-indexcache 2>/dev/null || true
/etc/init.d/rpcd restart
/etc/init.d/uhttpd restart
echo "=== post-install snapshot ==="
{
for f in $IMMU; do
s=$(sha256 "$f" 2>/dev/null) || s=missing
printf "%s %s\n" "$s" "$f"
done
} > post.sha256
cat post.sha256
if cmp -s pre.sha256 post.sha256; then
echo "PASS: immutable files unchanged"
else
echo "FAIL: immutable files differ; diff:"
diff -u pre.sha256 post.sha256 || true
fi
@@ -0,0 +1,48 @@
#!/bin/sh
# v46.1 UI-only rollback watchdog
# - Removes ONLY the preview IPKs (luci-theme-argon, luci-app-argon-config, luci-i18n-argon-config-zh-cn, luci-app-wgtunnel, luci-app-tr3000-status)
# - Restores /luci-static/bootstrap as the active theme
# - Clears /tmp/luci-cache and the WG apply state directory
# - Restarts rpcd + uhttpd only. Does NOT touch network / firewall / Wi-Fi / WG / VXLAN.
# - Does NOT call opkg update or fetch anything from the network. Bootstrap is
# already part of the v46 image; we only flip mediaurlbase to it.
set -eu
PREVIEW_LUCI="
luci-theme-argon
luci-app-argon-config
luci-i18n-argon-config-zh-cn
luci-app-wgtunnel
luci-app-tr3000-status
"
# 1. Switch active theme to bootstrap (already present in the v46 image)
uci set luci.main.mediaurlbase=/luci-static/bootstrap
uci commit luci
# 2. Drop any in-memory Argon config so the admin does not "see" an empty form
uci -q delete argon.@global[0] 2>/dev/null || true
uci commit argon 2>/dev/null || true
# 3. Clean LuCI cache
rm -rf /tmp/luci-cache /tmp/luci-indexcache 2>/dev/null || true
rm -rf /www/luci-static/argon/background 2>/dev/null || true
mkdir -p /www/luci-static/argon/background
chmod 0755 /www/luci-static/argon/background
# 4. Clear the WG apply state directory (token / snapshot / lock). It is a
# DIRECTORY created by luci.wgtunnel, not a file.
rm -rf /tmp/luci-wgtunnel 2>/dev/null || true
# 5. Remove preview IPKs only
for pkg in $PREVIEW_LUCI; do
if opkg list-installed | awk '{print $1}' | grep -qx "$pkg"; then
opkg remove --autoremove "$pkg" || true
fi
done
# 6. Restart only web/rpcd - never network, never firewall, never wifi
/etc/init.d/rpcd restart >/dev/null 2>&1 || true
/etc/init.d/uhttpd restart >/dev/null 2>&1 || true
echo "v46.1 UI rollback watchdog: done; theme bootstrap, preview IPKs removed, web stack restarted"
+46
View File
@@ -0,0 +1,46 @@
CONFIG_TARGET_mediatek=y
CONFIG_TARGET_mediatek_filogic=y
CONFIG_TARGET_mediatek_filogic_DEVICE_cudy_tr3000-256mb-v1=y
CONFIG_PACKAGE_ip-bridge=y
CONFIG_PACKAGE_ip-full=y
CONFIG_PACKAGE_iperf3=y
CONFIG_PACKAGE_iw-full=m
CONFIG_PACKAGE_kmod-br-netfilter=y
CONFIG_PACKAGE_kmod-crypto-lib-chacha20=y
CONFIG_PACKAGE_kmod-crypto-lib-chacha20poly1305=y
CONFIG_PACKAGE_kmod-crypto-lib-curve25519=y
CONFIG_PACKAGE_kmod-crypto-lib-poly1305=y
CONFIG_PACKAGE_kmod-ipt-core=y
CONFIG_PACKAGE_kmod-iptunnel=y
CONFIG_PACKAGE_kmod-nf-ipt=y
CONFIG_PACKAGE_kmod-udptunnel4=y
CONFIG_PACKAGE_kmod-udptunnel6=y
CONFIG_PACKAGE_kmod-vxlan=y
CONFIG_PACKAGE_kmod-wireguard=y
CONFIG_PACKAGE_libatomic=y
CONFIG_PACKAGE_libcap=y
CONFIG_PACKAGE_libcap-ng=y
CONFIG_PACKAGE_libiperf3=y
CONFIG_PACKAGE_libpcap=y
CONFIG_PACKAGE_libuv=y
CONFIG_PACKAGE_libwebsockets-full=y
CONFIG_PACKAGE_luci-app-argon-config=y
CONFIG_PACKAGE_luci-app-tr3000-status=y
CONFIG_PACKAGE_luci-app-ttyd=y
CONFIG_PACKAGE_luci-app-upnp=y
CONFIG_PACKAGE_luci-app-wgtunnel=y
CONFIG_PACKAGE_luci-i18n-argon-config-zh-cn=y
CONFIG_PACKAGE_luci-i18n-ttyd-zh-cn=y
CONFIG_PACKAGE_luci-i18n-upnp-zh-cn=y
CONFIG_PACKAGE_luci-lib-uqr=y
CONFIG_PACKAGE_luci-proto-wireguard=y
CONFIG_PACKAGE_luci-ssl-openssl=y
CONFIG_PACKAGE_luci-theme-argon=y
CONFIG_PACKAGE_miniupnpd-nftables=y
CONFIG_PACKAGE_openssl-util=y
CONFIG_PACKAGE_resolveip=y
CONFIG_PACKAGE_tcpdump=y
CONFIG_PACKAGE_ttyd=y
CONFIG_PACKAGE_vxlan=y
CONFIG_PACKAGE_wireguard-tools=y
# CONFIG_PACKAGE_kmod-crypto-kpp is not set
+263
View File
@@ -0,0 +1,263 @@
autocore - 42
automount - 10
base-files - 1~fa66185
block-mount - 2024.07.14~408c2cc4-r1
bridger - 2024.04.22~40b1c5b6
busybox - 1.36.1-r3
ca-bundle - 20260601-r1
cgi-io - 2026.07.21~31cb3c89-r1
default-settings - 29
default-settings-chn - 29
dnsmasq-full - 2.93-r1
dropbear - 2024.86-r3
e2fsprogs - 1.47.0-r2
eip197-mini-firmware - 20241110-r2
firewall4 - 2024.12.18~18fc0ead-r1
fitblk - 2
fstools - 2024.07.14~408c2cc4-r1
fwtool - 2019.11.12~8f7fe925-r1
getrandom - 2024.04.26~85f10530-r1
hostapd-common - 2024.09.15~5ace39b0-r3
ip-bridge - 6.11.0-r1
ip-full - 6.11.0-r1
iperf3 - 3.17.1-r4
iw - 6.9-r1
iwinfo - 2024.10.20~b94f066e-r1
jansson4 - 2.14-r3
jshn - 2025.07.23~49056d17-r1
jsonfilter - 2025.04.18~8a86fb78-r1
kernel - 6.6.151~a0dd982c80770480bd72b3bd339d29fe-r1
kmod-asn1-decoder - 6.6.151-r1
kmod-br-netfilter - 6.6.151-r1
kmod-cfg80211 - 6.6.151.6.12.96-r1
kmod-crypto-aead - 6.6.151-r1
kmod-crypto-arc4 - 6.6.151-r1
kmod-crypto-authenc - 6.6.151-r1
kmod-crypto-ccm - 6.6.151-r1
kmod-crypto-cmac - 6.6.151-r1
kmod-crypto-crc32c - 6.6.151-r1
kmod-crypto-ctr - 6.6.151-r1
kmod-crypto-des - 6.6.151-r1
kmod-crypto-ecb - 6.6.151-r1
kmod-crypto-gcm - 6.6.151-r1
kmod-crypto-geniv - 6.6.151-r1
kmod-crypto-gf128 - 6.6.151-r1
kmod-crypto-ghash - 6.6.151-r1
kmod-crypto-hash - 6.6.151-r1
kmod-crypto-hmac - 6.6.151-r1
kmod-crypto-hw-safexcel - 6.6.151-r1
kmod-crypto-lib-chacha20 - 6.6.151-r1
kmod-crypto-lib-chacha20poly1305 - 6.6.151-r1
kmod-crypto-lib-curve25519 - 6.6.151-r1
kmod-crypto-lib-poly1305 - 6.6.151-r1
kmod-crypto-manager - 6.6.151-r1
kmod-crypto-md5 - 6.6.151-r1
kmod-crypto-null - 6.6.151-r1
kmod-crypto-rng - 6.6.151-r1
kmod-crypto-seqiv - 6.6.151-r1
kmod-crypto-sha1 - 6.6.151-r1
kmod-crypto-sha256 - 6.6.151-r1
kmod-crypto-sha3 - 6.6.151-r1
kmod-crypto-sha512 - 6.6.151-r1
kmod-crypto-user - 6.6.151-r1
kmod-fs-exfat - 6.6.151-r1
kmod-fs-ext4 - 6.6.151-r1
kmod-fs-ntfs3 - 6.6.151-r1
kmod-fs-vfat - 6.6.151-r1
kmod-gpio-button-hotplug - 6.6.151-r5
kmod-hwmon-core - 6.6.151-r1
kmod-ipt-core - 6.6.151-r1
kmod-iptunnel - 6.6.151-r1
kmod-leds-gpio - 6.6.151-r1
kmod-lib-crc-ccitt - 6.6.151-r1
kmod-lib-crc16 - 6.6.151-r1
kmod-lib-crc32c - 6.6.151-r1
kmod-lib-textsearch - 6.6.151-r1
kmod-libphy - 6.6.151-r1
kmod-mac80211 - 6.6.151.6.12.96-r1
kmod-macvlan - 6.6.151-r1
kmod-mppe - 6.6.151-r1
kmod-mt76-connac - 6.6.151.2025.11.06~eb567bc7-r1
kmod-mt76-core - 6.6.151.2025.11.06~eb567bc7-r1
kmod-mt7915e - 6.6.151.2025.11.06~eb567bc7-r1
kmod-mt7981-firmware - 6.6.151.2025.11.06~eb567bc7-r1
kmod-nf-conntrack - 6.6.151-r1
kmod-nf-conntrack-netlink - 6.6.151-r1
kmod-nf-conntrack6 - 6.6.151-r1
kmod-nf-flow - 6.6.151-r1
kmod-nf-ipt - 6.6.151-r1
kmod-nf-log - 6.6.151-r1
kmod-nf-log6 - 6.6.151-r1
kmod-nf-nat - 6.6.151-r1
kmod-nf-nathelper - 6.6.151-r1
kmod-nf-nathelper-extra - 6.6.151-r1
kmod-nf-reject - 6.6.151-r1
kmod-nf-reject6 - 6.6.151-r1
kmod-nfnetlink - 6.6.151-r1
kmod-nft-core - 6.6.151-r1
kmod-nft-fib - 6.6.151-r1
kmod-nft-fullcone - 6.6.151.2023.05.17~07d93b62-r2
kmod-nft-nat - 6.6.151-r1
kmod-nft-offload - 6.6.151-r1
kmod-nls-base - 6.6.151-r1
kmod-nls-cp437 - 6.6.151-r1
kmod-nls-iso8859-1 - 6.6.151-r1
kmod-nls-utf8 - 6.6.151-r1
kmod-phy-aquantia - 6.6.151-r1
kmod-ppp - 6.6.151-r1
kmod-pppoe - 6.6.151-r1
kmod-pppox - 6.6.151-r1
kmod-sched-act-vlan - 6.6.151-r1
kmod-sched-bpf - 6.6.151-r1
kmod-sched-core - 6.6.151-r1
kmod-sched-flower - 6.6.151-r1
kmod-scsi-core - 6.6.151-r1
kmod-slhc - 6.6.151-r1
kmod-thermal - 6.6.151-r1
kmod-udptunnel4 - 6.6.151-r1
kmod-udptunnel6 - 6.6.151-r1
kmod-usb-core - 6.6.151-r1
kmod-usb-storage - 6.6.151-r1
kmod-usb-storage-extras - 6.6.151-r1
kmod-usb-storage-uas - 6.6.151-r1
kmod-usb-xhci-hcd - 6.6.151-r1
kmod-usb-xhci-mtk - 6.6.151-r1
kmod-usb3 - 6.6.151-r1
kmod-vxlan - 6.6.151-r1
kmod-wireguard - 6.6.151-r1
libatomic1 - 13.3.0-r4
libblkid1 - 2.40.2-r1
libblobmsg-json20240329 - 2025.07.23~49056d17-r1
libbpf1 - 1.5.0-r1
libc - 1.2.5-r4
libcap - 2.69-r1
libcap-ng - 0.8.4-r1
libcomerr0 - 1.47.0-r2
libe2p2 - 1.47.0-r2
libelf1 - 0.192-r1
libext2fs2 - 1.47.0-r2
libgcc1 - 13.3.0-r4
libgmp10 - 6.3.0-r1
libiperf3 - 3.17.1-r4
libiwinfo-data - 2024.10.20~b94f066e-r1
libiwinfo20230701 - 2024.10.20~b94f066e-r1
libjson-c5 - 0.18-r1
libjson-script20240329 - 2025.07.23~49056d17-r1
liblua5.1.5 - 5.1.5-r11
liblucihttp-lua - 2023.03.15~9b5b683f-r1
liblucihttp-ucode - 2023.03.15~9b5b683f-r1
liblucihttp0 - 2023.03.15~9b5b683f-r1
libmnl0 - 1.0.5-r1
libnetfilter-conntrack3 - 1.0.9-r2
libnettle8 - 3.9.1-r1
libnfnetlink0 - 1.0.2-r1
libnftnl11 - 1.2.8-r1
libnl-tiny1 - 2025.03.19~c0df580a-r1
libopenssl-conf - 3.0.21-r1
libopenssl-legacy - 3.0.21-r1
libopenssl3 - 3.0.21-r1
libpcap1 - 1.10.5-r2
libpthread - 1.2.5-r4
librt - 1.2.5-r4
libss2 - 1.47.0-r2
libubox20240329 - 2025.07.23~49056d17-r1
libubus-lua - 2025.10.17~60e04048-r1
libubus20250102 - 2025.10.17~60e04048-r1
libuci20250120 - 2025.01.20~16ff0bad-r1
libuclient20201210 - 2024.10.22~88ae8f20-r1
libucode20230711 - 2025.07.18~3f64c808-r1
libudebug - 2025.08.24~edeb4d6d
libustream-openssl20201210 - 2026.03.01~99f1c0db-r1
libuuid1 - 2.40.2-r1
libuv1 - 1.48.0-r1
libwebsockets-full - 4.3.3-r1
logd - 2024.04.26~85f10530-r1
lua - 5.1.5-r11
luci - 26.187.07912~668cdc6
luci-app-argon-config - 26.187.07912~668cdc6
luci-app-firewall - 26.187.07912~668cdc6
luci-app-package-manager - 26.187.07912~668cdc6
luci-app-tr3000-status - 0
luci-app-ttyd - 26.187.07912~668cdc6
luci-app-upnp - 26.203.12170~9bcb818
luci-app-wgtunnel - 0
luci-base - 26.223.24578~350d41d
luci-compat - 26.187.07912~668cdc6
luci-i18n-argon-config-zh-cn - 26.187.07912~668cdc6
luci-i18n-base-zh-cn - 26.208.15216~4936dfe
luci-i18n-firewall-zh-cn - 26.208.15216~4936dfe
luci-i18n-package-manager-zh-cn - 26.204.72559~a5c40d1
luci-i18n-ttyd-zh-cn - 26.187.07912~668cdc6
luci-i18n-upnp-zh-cn - 26.208.15216~4936dfe
luci-lib-base - 26.187.07912~668cdc6
luci-lib-ip - 26.187.07912~668cdc6
luci-lib-ipkg - 26.187.07912~668cdc6
luci-lib-jsonc - 26.187.07912~668cdc6
luci-lib-nixio - 26.187.07912~668cdc6
luci-lib-uqr - 26.187.07912~668cdc6
luci-light - 26.187.07912~668cdc6
luci-lua-runtime - 26.223.24578~350d41d
luci-mod-admin-full - 26.187.07912~668cdc6
luci-mod-network - 26.203.12170~9bcb818
luci-mod-status - 26.203.12170~9bcb818
luci-mod-system - 26.208.15216~4936dfe
luci-proto-ipv6 - 26.187.07912~668cdc6
luci-proto-ppp - 26.187.07912~668cdc6
luci-proto-wireguard - 26.187.07912~668cdc6
luci-ssl-openssl - 26.187.07912~668cdc6
luci-theme-argon - 2.4.3-r20250722
luci-theme-bootstrap - 26.187.07912~668cdc6
miniupnpd-nftables - 2.3.9-r1
mt7981-wo-firmware - 20241110-r2
mtd - 26
netifd - 2025.05.23~7901e66c-r1
nftables-json - 1.1.1-r1
ntfs3-mount - 10
odhcp6c - 2024.09.25~b6ae9ffa-r2
odhcpd-ipv6only - 2025.10.02~b14cf98c-r3
openssl-util - 3.0.21-r1
openwrt-keyring - 2024.11.01~c5d6bdf2-r1
opkg - 2024.10.16~38eccbb1-r1
ppp - 2.5.1-r1
ppp-mod-pppoe - 2.5.1-r1
procd - 2026.03.14~c59f2d80-r1
procd-seccomp - 2026.03.14~c59f2d80-r1
procd-ujail - 2026.03.14~c59f2d80-r1
resolveip - 2
rpcd - 2025.09.01~bba95191-r2
rpcd-mod-file - 2025.09.01~bba95191-r2
rpcd-mod-iwinfo - 2025.09.01~bba95191-r2
rpcd-mod-luci - 20240305-r1
rpcd-mod-rrdns - 20170710
rpcd-mod-ucode - 2025.09.01~bba95191-r2
shellsync - 0.2-r2
tcpdump - 4.99.5-r1
ttyd - 1.7.3-r1
ubi-utils - 2.2.1-r1
uboot-envtools - 2024.07-r1
ubox - 2024.04.26~85f10530-r1
ubus - 2025.10.17~60e04048-r1
ubusd - 2025.10.17~60e04048-r1
uci - 2025.01.20~16ff0bad-r1
uclient-fetch - 2024.10.22~88ae8f20-r1
ucode - 2025.07.18~3f64c808-r1
ucode-mod-fs - 2025.07.18~3f64c808-r1
ucode-mod-html - 1
ucode-mod-lua - 1
ucode-mod-math - 2025.07.18~3f64c808-r1
ucode-mod-nl80211 - 2025.07.18~3f64c808-r1
ucode-mod-rtnl - 2025.07.18~3f64c808-r1
ucode-mod-ubus - 2025.07.18~3f64c808-r1
ucode-mod-uci - 2025.07.18~3f64c808-r1
ucode-mod-uloop - 2025.07.18~3f64c808-r1
uhttpd - 2025.07.06~7e64e8ba-r5
uhttpd-mod-ubus - 2025.07.06~7e64e8ba-r5
urandom-seed - 3
urngd - 2023.11.01~44365eb1-r1
usign - 2020.05.23~f1f65026-r1
vxlan - 7
wifi-scripts - 1.0-r1
wireguard-tools - 1.0.20210914-r4
wireless-regdb - 2026.05.30-r1
wpad-openssl - 2024.09.15~5ace39b0-r3
zlib - 1.3.1-r1