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:
@@ -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 均不修改。
|
||||
+235
@@ -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
|
||||
});
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"admin/status/tr3000": {
|
||||
"title": "Link Health",
|
||||
"order": 25,
|
||||
"action": {
|
||||
"type": "view",
|
||||
"path": "status/tr3000"
|
||||
},
|
||||
"depends": {
|
||||
"acl": [ "luci-app-tr3000-status" ]
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"luci-app-tr3000-status": {
|
||||
"description": "Read sanitized TR3000 Link Health status",
|
||||
"read": {
|
||||
"ubus": {
|
||||
"luci.tr3000_status": [ "get" ]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+565
@@ -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 };
|
||||
+213
@@ -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())
|
||||
Vendored
+19
@@ -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" }
|
||||
}
|
||||
}
|
||||
+6
@@ -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 }
|
||||
}
|
||||
+5
@@ -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"]
|
||||
}
|
||||
+5
@@ -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"]
|
||||
}
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"scenario": "mss_missing",
|
||||
"expect_partial": false,
|
||||
"expect_mss_clamp_1330": false,
|
||||
"expect_summary": { "ok": false, "status": "degraded", "invariant_ok": false }
|
||||
}
|
||||
+6
@@ -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"
|
||||
}
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"scenario": "no_hwmon",
|
||||
"expect_partial": true,
|
||||
"expect_errors": ["HWMON_TEMPERATURE_UNAVAILABLE"]
|
||||
}
|
||||
immortalwrt-patches/feeds/luci/applications/luci-app-tr3000-status/tests/fixtures/route_missing.json
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"scenario": "route_missing",
|
||||
"expect_partial": false,
|
||||
"expect_route_present": false,
|
||||
"expect_summary": { "ok": false, "status": "degraded", "route_ok": false }
|
||||
}
|
||||
immortalwrt-patches/feeds/luci/applications/luci-app-tr3000-status/tests/fixtures/vxlan_missing.json
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"scenario": "vxlan_missing",
|
||||
"expect_partial": false,
|
||||
"expect_vxlan_present": false,
|
||||
"expect_summary": { "ok": false, "status": "degraded", "vxlan_ok": false }
|
||||
}
|
||||
+8
@@ -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 }
|
||||
}
|
||||
+7
@@ -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 }
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"scenario": "wg_stale_handshake",
|
||||
"expect_partial": false,
|
||||
"expect_wireguard_status": "stale",
|
||||
"expect_summary": { "ok": false, "status": "degraded", "wg_ok": false }
|
||||
}
|
||||
+31
@@ -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"
|
||||
+27
@@ -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'
|
||||
"
|
||||
+46
@@ -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())
|
||||
+31
@@ -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"
|
||||
Reference in New Issue
Block a user