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,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"
|
||||
Reference in New Issue
Block a user