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,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"