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

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

固件本体(含烤入 WG 私钥/PSK)不入 git, 仅 K 盘保存.
kernel 成员与 v46 byte-identical; 尚未刷机.
This commit is contained in:
2026-08-19 15:04:02 +08:00
parent c6c90c1db8
commit 29cf67154a
92 changed files with 16036 additions and 0 deletions
@@ -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"