Files
x1pro/immortalwrt-patches/tools/audit_ui_packages.py
T
zhangxiaokun 29cf67154a 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; 尚未刷机.
2026-08-19 15:04:02 +08:00

189 lines
6.9 KiB
Python

#!/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())