29cf67154a
基于 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; 尚未刷机.
189 lines
6.9 KiB
Python
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())
|