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; 尚未刷机.
@@ -2,5 +2,16 @@
|
|||||||
|
|
||||||
这是一个x1pro 刷机成cudytr3000
|
这是一个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-identical;rootfs +408KB 全 UI,denylist 零变化
|
||||||
|
- **固件本体含烤入的 WG 私钥/PSK,未放入 git**(存 K 盘),hash 见 `docs/固件哈希记录.md`
|
||||||
|
- 完整开发经历与翻车记录:`docs/project_v46_v461_development_and_failures.md`
|
||||||
|
- 尚未刷机实机验收
|
||||||
|
|
||||||
## v37 (2026-07-25)
|
## 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` (本地内存索引).
|
`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-19,Cudy 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-18,precal-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_LABEL(md5 `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 进 INPUT,fw4 只接受 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-in;v45.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 新增就该立刻警觉。
|
||||||
|
|
||||||
|
### 翻车 2:theme-argon 的 6 张图片被源树「删掉」
|
||||||
|
|
||||||
|
feeds/luci 工作区里 `argon.svg` logo、**`bg1.jpg` 壁纸 fallback(159KB)**、`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`
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# 固件哈希记录
|
||||||
|
|
||||||
|
## v46-formal-mt76(mt76 正式版,已刷机实机验收)
|
||||||
|
- `sysupgrade-v46-formal-mt76.bin` 14,388,023 B
|
||||||
|
- SHA-256 `261bcb92062b358dd8b5fbdf88e3a2b7e16311dd4f09c3235a4ea802b88a602c`
|
||||||
|
|
||||||
|
## v46.1-ui-mt76(UI-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
|
||||||
|
After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
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>
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
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 |
|
After Width: | Height: | Size: 156 KiB |
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||