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,188 @@
#!/usr/bin/env python3
"""Static safety gates for the v46.1 UI-only LuCI packages."""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent
EXPECTED_PACKAGES = {
"luci-theme-argon",
"luci-app-argon-config",
"luci-app-wgtunnel",
"luci-app-tr3000-status",
}
FORBIDDEN_PACKAGES = {
"luci-app-turboacc-mtk",
"luci-app-wrtbwmon",
"kmod-mediatek_hnat",
"kmod-warp",
"kmod-mt_wifi",
"kmod-tcp-bbr",
}
GLOBAL_FORBIDDEN = {
"ifup lan": "must never bounce the management LAN",
"wifi restart": "must never reset Wi-Fi from a UI package",
"/etc/init.d/network restart": "must never restart the global network",
"/etc/init.d/firewall restart": "must never restart fw4",
}
FRONTEND_FORBIDDEN = {
"handleSaveApply(": "WG UI must not invoke LuCI global apply",
"fs.exec": "browser code must not execute programs",
"uci.load('network')": "WG UI must not receive generic network UCI access",
'uci.load("network")': "WG UI must not receive generic network UCI access",
"new form.Map('network'": "WG UI must use JSONMap instead of network UCI",
'new form.Map("network"': "WG UI must use JSONMap instead of network UCI",
}
KEY_LIKE = re.compile(r"(?<![A-Za-z0-9+/])[A-Za-z0-9+/]{43}=(?![A-Za-z0-9+/=])")
def fail(message: str) -> None:
print(f"FAIL: {message}")
raise SystemExit(1)
def text_files(package: Path):
for path in sorted(package.rglob("*")):
if not path.is_file():
continue
# Repo-only documentation (not shipped in the IPK) may legitimately
# name forbidden patterns when describing them; only scan shipped/code
# files for the forbidden-content gates.
if path.suffix == ".md" or path.name.upper().startswith("README"):
continue
# Test harnesses (not shipped) legitimately reference forbidden package
# names when asserting they are absent; skip them too.
if "tests" in path.relative_to(package).parts:
continue
try:
yield path, path.read_text(encoding="utf-8")
except UnicodeDecodeError:
continue
def load_json(path: Path) -> dict:
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
fail(f"invalid JSON {path.relative_to(ROOT)}: {exc}")
def sole_acl(path: Path, name: str) -> dict:
data = load_json(path)
if set(data) != {name}:
fail(f"{path.relative_to(ROOT)} must contain only ACL {name}")
return data[name]
def assert_ubus_only(block: dict, obj: str, methods: set[str]) -> None:
if set(block) != {"ubus"}:
fail(f"ACL block for {obj} must grant ubus only, got {sorted(block)}")
ubus = block["ubus"]
if set(ubus) != {obj} or set(ubus[obj]) != methods:
fail(f"ACL for {obj} expected methods {sorted(methods)}, got {ubus}")
def check_wgtunnel() -> None:
package = ROOT / "luci-app-wgtunnel"
acl = sole_acl(
package / "root/usr/share/rpcd/acl.d/luci-app-wgtunnel.json",
"luci-app-wgtunnel",
)
assert_ubus_only(acl.get("read", {}), "luci.wgtunnel", {"get", "status"})
assert_ubus_only(
acl.get("write", {}),
"luci.wgtunnel",
{"prepare", "apply", "rollback", "reconnect"},
)
frontend = package / "htdocs/luci-static/resources/view/wgtunnel.js"
source = frontend.read_text(encoding="utf-8")
for needle, reason in FRONTEND_FORBIDDEN.items():
if needle in source:
fail(f"{frontend.relative_to(ROOT)} contains {needle!r}: {reason}")
for required in ("form.JSONMap", "luci.wgtunnel", "prepare", "apply"):
if required not in source:
fail(f"{frontend.relative_to(ROOT)} lacks {required!r}")
backend = package / "root/usr/share/rpcd/ucode/luci.wgtunnel"
if not backend.is_file():
fail(f"missing {backend.relative_to(ROOT)}")
source = backend.read_text(encoding="utf-8")
for method in ("get", "status", "prepare", "apply", "rollback", "reconnect"):
if re.search(rf"\b{re.escape(method)}\s*:", source) is None:
fail(f"WG backend lacks method {method}")
for required in ("10.99.0.2/32", "10.99.0.1/32", "wg0", "vxlan0"):
if required not in source:
fail(f"WG backend lacks topology fuse {required}")
def check_status() -> None:
package = ROOT / "luci-app-tr3000-status"
acl = sole_acl(
package / "root/usr/share/rpcd/acl.d/luci-app-tr3000-status.json",
"luci-app-tr3000-status",
)
assert_ubus_only(acl.get("read", {}), "luci.tr3000_status", {"get"})
if "write" in acl:
fail("Link Health ACL must not contain a write block")
backend = package / "root/usr/share/rpcd/ucode/luci.tr3000_status"
frontend = package / "htdocs/luci-static/resources/view/status/tr3000.js"
if not backend.is_file() or not frontend.is_file():
fail("Link Health backend/frontend is missing")
for path in (backend, frontend):
source = path.read_text(encoding="utf-8")
for needle in ("private_key", "preshared_key", "wifi-key", "wireless key"):
if needle in source.lower():
fail(f"{path.relative_to(ROOT)} references secret field {needle!r}")
def check_argon_acl() -> None:
path = ROOT / "luci-app-argon-config/root/usr/share/rpcd/acl.d/luci-app-argon-config.json"
acl = sole_acl(path, "luci-app-argon-config")
read_ubus = acl.get("read", {}).get("ubus", {})
write_ubus = acl.get("write", {}).get("ubus", {})
mutators = {"remove", "rename"}
if mutators & set(read_ubus.get("luci.argon", [])):
fail("Argon remove/rename methods must not be granted in read ACL")
if not mutators <= set(write_ubus.get("luci.argon", [])):
fail("Argon write ACL must grant remove and rename")
def check_all_sources() -> None:
packages = {path.name for path in ROOT.iterdir() if path.is_dir() and path.name.startswith("luci-")}
missing = EXPECTED_PACKAGES - packages
if missing:
fail(f"missing package directories: {sorted(missing)}")
for package_name in sorted(EXPECTED_PACKAGES):
package = ROOT / package_name
for path, source in text_files(package):
rel = path.relative_to(ROOT)
for needle, reason in GLOBAL_FORBIDDEN.items():
if needle in source:
fail(f"{rel} contains {needle!r}: {reason}")
for forbidden in FORBIDDEN_PACKAGES:
if forbidden in source:
fail(f"{rel} pulls forbidden package {forbidden}")
if KEY_LIKE.search(source):
fail(f"{rel} contains a possible WireGuard key literal")
if path.suffix == ".json":
load_json(path)
def main() -> int:
check_all_sources()
check_argon_acl()
check_wgtunnel()
check_status()
print("PASS: v46.1 UI-only static safety gates")
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,86 @@
#!/bin/sh
# v46.1 UI-only live install helper
# - Only installs the 5 audited IPKs (NO opkg update, NO network/firewall/wifi changes)
# - Pre-checks live immutable hashes, then installs, then post-checks
# - All operations are idempotent; safe to re-run after a partial failure
set -eu
BASE=/tmp/v46.1-ui
mkdir -p "$BASE"
cd "$BASE"
PKGS="
luci-theme-argon_2.4.3-r20250722_all.ipk
luci-app-argon-config_26.187.07912~668cdc6_all.ipk
luci-i18n-argon-config-zh-cn_26.187.07912~668cdc6_all.ipk
luci-app-wgtunnel_0_all.ipk
luci-app-tr3000-status_0_all.ipk
"
IMMU="
/etc/config/network
/etc/config/firewall
/etc/config/dhcp
/etc/config/wireless
/etc/rc.local
/etc/hotplug.d/iface/20-vxlan
/etc/hotplug.d/iface/30-mss-clamp
/usr/share/nftables.d/chain-pre/mangle_forward/30-mss-clamp.nft
"
sha256() { sha256sum "$1" 2>/dev/null | awk '{print $1}'; }
echo "=== pre-install snapshot ==="
{
for f in $IMMU; do
s=$(sha256 "$f" 2>/dev/null) || s=missing
printf "%s %s\n" "$s" "$f"
done
} > pre.sha256
cat pre.sha256
missing=""
for p in $PKGS; do
if [ ! -f "$BASE/$p" ]; then
missing="$missing $p"
fi
done
if [ -n "$missing" ]; then
echo "FAIL: missing IPK files in $BASE:$missing"
exit 1
fi
for p in $PKGS; do
echo "=== opkg install --force-reinstall $p ==="
opkg install --force-reinstall --noaction "$p" || true
done
for p in $PKGS; do
opkg install --force-reinstall "$p"
done
# Keep the active theme on Bootstrap until admin chooses Argon.
# To activate Argon:
# uci set luci.main.mediaurlbase=/luci-static/argon
# uci commit luci
# /etc/init.d/rpcd restart; /etc/init.d/uhttpd restart
rm -rf /tmp/luci-cache /tmp/luci-indexcache 2>/dev/null || true
/etc/init.d/rpcd restart
/etc/init.d/uhttpd restart
echo "=== post-install snapshot ==="
{
for f in $IMMU; do
s=$(sha256 "$f" 2>/dev/null) || s=missing
printf "%s %s\n" "$s" "$f"
done
} > post.sha256
cat post.sha256
if cmp -s pre.sha256 post.sha256; then
echo "PASS: immutable files unchanged"
else
echo "FAIL: immutable files differ; diff:"
diff -u pre.sha256 post.sha256 || true
fi
@@ -0,0 +1,48 @@
#!/bin/sh
# v46.1 UI-only rollback watchdog
# - Removes ONLY the preview IPKs (luci-theme-argon, luci-app-argon-config, luci-i18n-argon-config-zh-cn, luci-app-wgtunnel, luci-app-tr3000-status)
# - Restores /luci-static/bootstrap as the active theme
# - Clears /tmp/luci-cache and the WG apply state directory
# - Restarts rpcd + uhttpd only. Does NOT touch network / firewall / Wi-Fi / WG / VXLAN.
# - Does NOT call opkg update or fetch anything from the network. Bootstrap is
# already part of the v46 image; we only flip mediaurlbase to it.
set -eu
PREVIEW_LUCI="
luci-theme-argon
luci-app-argon-config
luci-i18n-argon-config-zh-cn
luci-app-wgtunnel
luci-app-tr3000-status
"
# 1. Switch active theme to bootstrap (already present in the v46 image)
uci set luci.main.mediaurlbase=/luci-static/bootstrap
uci commit luci
# 2. Drop any in-memory Argon config so the admin does not "see" an empty form
uci -q delete argon.@global[0] 2>/dev/null || true
uci commit argon 2>/dev/null || true
# 3. Clean LuCI cache
rm -rf /tmp/luci-cache /tmp/luci-indexcache 2>/dev/null || true
rm -rf /www/luci-static/argon/background 2>/dev/null || true
mkdir -p /www/luci-static/argon/background
chmod 0755 /www/luci-static/argon/background
# 4. Clear the WG apply state directory (token / snapshot / lock). It is a
# DIRECTORY created by luci.wgtunnel, not a file.
rm -rf /tmp/luci-wgtunnel 2>/dev/null || true
# 5. Remove preview IPKs only
for pkg in $PREVIEW_LUCI; do
if opkg list-installed | awk '{print $1}' | grep -qx "$pkg"; then
opkg remove --autoremove "$pkg" || true
fi
done
# 6. Restart only web/rpcd - never network, never firewall, never wifi
/etc/init.d/rpcd restart >/dev/null 2>&1 || true
/etc/init.d/uhttpd restart >/dev/null 2>&1 || true
echo "v46.1 UI rollback watchdog: done; theme bootstrap, preview IPKs removed, web stack restarted"