初始化奇妙小屏幕控制器项目

This commit is contained in:
2026-09-08 22:56:52 +08:00
commit 8d368de3b5
491 changed files with 67678 additions and 0 deletions
@@ -0,0 +1 @@
"""Deployment and maintenance helpers for Matrix Screen Controller."""
@@ -0,0 +1,182 @@
from __future__ import annotations
import argparse
import json
import os
import platform
import subprocess
import time
import urllib.request
from pathlib import Path
from typing import Any
CANDIDATE_RELEASE = "6.1.31-matrix-axp313a1"
MARKER_PATH = Path("/boot/matrix-kernel-good")
CPUFREQ_ROOT = Path("/sys/devices/system/cpu/cpufreq")
class HealthError(RuntimeError):
pass
def _read(path: Path) -> str:
return path.read_text(encoding="ascii", errors="strict").strip()
def check_axp313a_binding(sys_root: Path = Path("/sys")) -> None:
matches = list((sys_root / "bus/i2c/devices").glob("*-0036/of_node/compatible"))
if not matches:
raise HealthError("PMIC device-tree node at I2C address 0x36 is missing")
compatible = matches[0].read_bytes().replace(b"\0", b"\n").decode("ascii", errors="replace")
if "x-powers,axp313a" not in compatible.splitlines():
raise HealthError(f"PMIC compatible is not x-powers,axp313a: {compatible!r}")
driver_link = matches[0].parents[1] / "driver"
if not driver_link.exists() or Path(os.path.realpath(driver_link)).name != "axp20x-i2c":
raise HealthError("AXP313A is not bound to axp20x-i2c")
def check_cpufreq(
cpufreq_root: Path = CPUFREQ_ROOT,
debugfs_deferred: Path = Path("/sys/kernel/debug/devices_deferred"),
) -> None:
policy = cpufreq_root / "policy0"
if not policy.is_dir():
raise HealthError("cpufreq policy0 is missing")
affected = set(_read(policy / "affected_cpus").split())
if affected != {"0", "1", "2", "3"}:
raise HealthError(f"policy0 does not cover CPU0-3: {sorted(affected)}")
available = set(_read(policy / "scaling_available_governors").split())
if "performance" not in available:
raise HealthError("performance governor is unavailable")
if debugfs_deferred.is_file() and "cpufreq-dt" in debugfs_deferred.read_text(
encoding="utf-8", errors="replace"
):
raise HealthError("cpufreq-dt is still deferred")
def check_cpu_regulator(sys_root: Path = Path("/sys")) -> None:
regulator_root = sys_root / "class/regulator"
for directory in regulator_root.glob("regulator.*"):
name_path = directory / "name"
if not name_path.is_file() or _read(name_path) != "vdd-cpu":
continue
voltage = int(_read(directory / "microvolts"))
if not 810_000 <= voltage <= 1_080_000:
raise HealthError(f"vdd-cpu is outside the approved range: {voltage}uV")
return
raise HealthError("vdd-cpu regulator is missing")
def check_kernel_messages(messages: str) -> None:
forbidden = (
"DCDC frequency on AXP313a is fixed to 3 MHz",
"Error setting dcdc frequency",
)
for message in forbidden:
if message in messages:
raise HealthError(f"AXP313A regulator logged a boot error: {message}")
def check_kernel_log() -> None:
result = subprocess.run(
["/bin/dmesg"],
check=True,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
check_kernel_messages(result.stdout)
def _fetch_status(url: str) -> dict[str, Any]:
# The candidate kernel is first validated against the already-installed
# production application. That older build can take several seconds to
# answer while its display compositor is busy, which must not masquerade
# as a kernel/cpufreq failure. The stricter 500 ms HTTP performance gate
# is measured separately after the candidate application is staged.
with urllib.request.urlopen(url, timeout=10.0) as response:
return json.loads(response.read().decode("utf-8"))
def check_driver_pair(first: dict[str, Any], second: dict[str, Any]) -> None:
screen = second.get("screen") or {}
if screen.get("driver") != "walnutpi-h618-hub75":
raise HealthError("production H618 driver is not active")
driver = screen.get("driver_status") or {}
previous = (first.get("screen") or {}).get("driver_status") or {}
for field in ("actual_refresh_rate_hz", "panel_scan_rate_hz"):
if float(driver.get(field) or 0.0) < 95.0:
raise HealthError(f"{field} is below 95Hz")
if driver.get("scans_per_frame") != 1:
raise HealthError("scans_per_frame is not 1")
frame_delta = int(driver.get("completed_frames") or 0) - int(previous.get("completed_frames") or 0)
scan_delta = int(driver.get("completed_scans") or 0) - int(previous.get("completed_scans") or 0)
miss_delta = int(driver.get("deadline_misses") or 0) - int(previous.get("deadline_misses") or 0)
if frame_delta <= 0 or scan_delta != frame_delta:
raise HealthError("HUB75 completed frame/scan counters are stalled or unequal")
if miss_delta < 0 or miss_delta / frame_delta > 0.001:
raise HealthError("HUB75 deadline miss rate exceeds 0.1%")
for field in ("oe_pulse_faults", "oe_forced_blanks"):
if int(driver.get(field) or 0) != int(previous.get(field) or 0):
raise HealthError(f"{field} increased during health check")
for field in ("driver_error", "oe_timing_error", "safeoff_error"):
if driver.get(field):
raise HealthError(f"{field} is not empty: {driver[field]}")
if driver.get("cpu_affinity") != 3 or driver.get("cpu_affinity_active") is not True:
raise HealthError("CPU3 affinity is not active")
if driver.get("realtime_priority") != 50 or driver.get("realtime_priority_active") is not True:
raise HealthError("SCHED_FIFO 50 is not active")
if driver.get("memory_locked") is not True:
raise HealthError("refresh memory is not locked")
def wait_for_driver(url: str, timeout_seconds: int = 60) -> None:
deadline = time.monotonic() + timeout_seconds
last_error: Exception | None = None
while time.monotonic() < deadline:
try:
first = _fetch_status(url)
time.sleep(2)
second = _fetch_status(url)
check_driver_pair(first, second)
return
except Exception as exc:
last_error = exc
time.sleep(1)
raise HealthError(f"application/GPIO health timed out: {last_error}")
def write_marker(marker: Path = MARKER_PATH) -> None:
temporary = marker.with_name(f".{marker.name}.tmp")
with temporary.open("w", encoding="ascii", newline="\n") as handle:
handle.write(f"{CANDIDATE_RELEASE}\n")
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary, marker)
os.sync()
def main() -> int:
parser = argparse.ArgumentParser(description="Confirm the one-shot WalnutPi AXP313A kernel")
parser.add_argument("--status-url", default="http://127.0.0.1:8080/api/status")
parser.add_argument("--marker", type=Path, default=MARKER_PATH)
args = parser.parse_args()
if platform.release() != CANDIDATE_RELEASE:
raise HealthError(f"unexpected candidate release: {platform.release()}")
check_axp313a_binding()
check_cpufreq()
check_cpu_regulator()
check_kernel_log()
wait_for_driver(args.status_url)
write_marker(args.marker)
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except HealthError as exc:
print(f"AXP313A candidate health failed: {exc}", flush=True)
raise SystemExit(1)
@@ -0,0 +1,147 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
from dataclasses import asdict, dataclass
import json
import os
from pathlib import Path
import tarfile
from scripts.kernel_artifact import ARTIFACT_NAME, REQUIRED_BOOT_FILES, verify_kernel_dependency
from scripts.render_dual_kernel_boot import BootScriptError, render
DEFAULT_SAFETY_BYTES = 32 * 1024 * 1024
CONTROL_FILE_COUNT = 2 # matrix-original.SHA256SUMS and matrix-kernel-good
class BootSpaceError(ValueError):
pass
@dataclass(frozen=True)
class BootSpaceBudget:
required_bytes: int
safety_bytes: int
total_bytes: int
available_bytes: int
cluster_bytes: int
def _allocated(size: int, cluster_bytes: int) -> int:
if type(size) is not int or size < 0:
raise BootSpaceError("boot file size must be a non-negative integer")
if type(cluster_bytes) is not int or cluster_bytes <= 0:
raise BootSpaceError("boot cluster size must be a positive integer")
return max(1, (size + cluster_bytes - 1) // cluster_bytes) * cluster_bytes
def calculate_budget(
*,
candidate_sizes: dict[str, int],
boot_cmd: bytes,
boot_scr: bytes,
cluster_bytes: int,
available_bytes: int,
safety_bytes: int = DEFAULT_SAFETY_BYTES,
) -> BootSpaceBudget:
if set(candidate_sizes) != REQUIRED_BOOT_FILES:
raise BootSpaceError("candidate boot file set is not exact")
if type(available_bytes) is not int or available_bytes < 0:
raise BootSpaceError("available boot bytes must be a non-negative integer")
if type(safety_bytes) is not int or safety_bytes < 0:
raise BootSpaceError("boot safety bytes must be a non-negative integer")
try:
managed_cmd = render(boot_cmd.decode("utf-8")).encode("utf-8")
except (UnicodeError, BootScriptError) as exc:
raise BootSpaceError("original boot.cmd cannot be rendered safely") from exc
# mkimage adds a 64-byte legacy image header. Count both temporary and final
# managed scripts so the estimate remains safe even when FAT cannot reuse the
# original chains during replacement.
managed_scr_size = len(managed_cmd) + 64
allocations = list(candidate_sizes.values())
allocations.extend((len(boot_cmd), len(boot_scr))) # rollback copies
allocations.extend((len(managed_cmd), managed_scr_size)) # mktemp files
allocations.extend((len(managed_cmd), managed_scr_size)) # final replacements
allocations.extend(1 for _ in range(CONTROL_FILE_COUNT))
required = sum(_allocated(size, cluster_bytes) for size in allocations)
total = required + safety_bytes
return BootSpaceBudget(required, safety_bytes, total, available_bytes, cluster_bytes)
def candidate_sizes_from_dependency(dependency: Path) -> dict[str, int]:
dependency = Path(dependency).resolve()
verify_kernel_dependency(dependency)
with tarfile.open(dependency / ARTIFACT_NAME, mode="r:gz") as archive:
normalized = {
member.name.removeprefix("./"): member.size
for member in archive.getmembers()
if member.isfile()
}
return {name: normalized[name] for name in REQUIRED_BOOT_FILES}
def candidate_sizes_from_artifact_root(root: Path) -> dict[str, int]:
root = Path(root).resolve()
result: dict[str, int] = {}
for name in REQUIRED_BOOT_FILES:
path = root / name
if not path.is_file():
raise BootSpaceError(f"candidate boot file is missing: {name}")
result[name] = path.stat().st_size
return result
def check_mounted_boot(
boot_root: Path,
artifact_root: Path,
*,
safety_bytes: int = DEFAULT_SAFETY_BYTES,
) -> BootSpaceBudget:
boot_root = Path(boot_root).resolve()
if not boot_root.is_dir() or boot_root == Path(boot_root.anchor):
raise BootSpaceError("boot root must be an explicit mounted directory")
boot_cmd = (boot_root / "boot.cmd").read_bytes()
boot_scr = (boot_root / "boot.scr").read_bytes()
stats = os.statvfs(boot_root)
cluster_bytes = stats.f_frsize or stats.f_bsize
available_bytes = stats.f_bavail * cluster_bytes
budget = calculate_budget(
candidate_sizes=candidate_sizes_from_artifact_root(artifact_root),
boot_cmd=boot_cmd,
boot_scr=boot_scr,
cluster_bytes=cluster_bytes,
available_bytes=available_bytes,
safety_bytes=safety_bytes,
)
if budget.available_bytes < budget.total_bytes:
raise BootSpaceError(
"boot filesystem lacks room for the candidate kernel and rollback files: "
f"available={budget.available_bytes}, required={budget.required_bytes}, "
f" safety={budget.safety_bytes}, total={budget.total_bytes}"
)
return budget
def main() -> int:
parser = argparse.ArgumentParser(description="Validate safe /boot space before kernel installation")
parser.add_argument("--boot-root", type=Path, required=True)
parser.add_argument("--artifact-root", type=Path, required=True)
parser.add_argument("--safety-bytes", type=int, default=DEFAULT_SAFETY_BYTES)
args = parser.parse_args()
result = check_mounted_boot(
args.boot_root,
args.artifact_root,
safety_bytes=args.safety_bytes,
)
print(json.dumps(asdict(result), ensure_ascii=False, sort_keys=True))
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except (BootSpaceError, OSError, tarfile.TarError) as exc:
print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False))
raise SystemExit(1)
@@ -0,0 +1,105 @@
#!/bin/sh
set -eu
SOURCE=
CONFIG=
OUTPUT=
JOBS=${JOBS:-2}
RELEASE=6.1.31-matrix-axp313a1
LOCALVERSION_SUFFIX=-matrix-axp313a1
VENDOR_COMMIT=30ff3fd5cf45417622b447a12e7a947402ffe34d
while [ "$#" -gt 0 ]; do
case "$1" in
--source) SOURCE=$2; shift 2 ;;
--config) CONFIG=$2; shift 2 ;;
--output) OUTPUT=$2; shift 2 ;;
*) echo "unknown argument: $1" >&2; exit 2 ;;
esac
done
if [ -z "$SOURCE" ] || [ -z "$CONFIG" ] || [ -z "$OUTPUT" ]; then
echo "usage: build_axp313a_kernel.sh --source DIR --config FILE --output EMPTY_DIR" >&2
exit 2
fi
SOURCE=$(CDPATH= cd -- "$SOURCE" && pwd)
CONFIG=$(readlink -f -- "$CONFIG")
OUTPUT=$(readlink -f -- "$OUTPUT")
SCRIPT_ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
PATCH_ROOT=$SCRIPT_ROOT/kernel/patches
BUILD=$OUTPUT/build
STAGE=$OUTPUT/stage
case "$SOURCE" in /|/boot|/opt|/usr|/var|/home) echo "source path is too broad" >&2; exit 1 ;; esac
case "$OUTPUT" in /|/boot|/opt|/usr|/var|/home) echo "output path is too broad" >&2; exit 1 ;; esac
if [ ! -f "$SOURCE/Makefile" ] || [ ! -f "$CONFIG" ]; then
echo "kernel source or config is missing" >&2
exit 1
fi
if [ -n "$(find "$OUTPUT" -mindepth 1 -maxdepth 1 -print -quit)" ]; then
echo "output directory must be empty" >&2
exit 1
fi
case "$JOBS" in *[!0-9]*|'') echo "JOBS must be a positive integer" >&2; exit 1 ;; esac
if [ "$JOBS" -lt 1 ] || [ "$JOBS" -gt 4 ]; then
echo "JOBS must be within 1..4" >&2
exit 1
fi
(cd "$PATCH_ROOT" && sha256sum -c SHA256SUMS)
for upstream_patch in \
75c8cb2f4cb218aaf4ea68cab08d6dbc96eeae15.patch \
60fd7eb89670d2636ac3156881acbd103c6eba6a.patch \
4628b804555773daa982d9578d89fe04fcdde374.patch
do
test -f "$PATCH_ROOT/$upstream_patch"
done
grep -q 'AXP313A_ID' "$SOURCE/include/linux/mfd/axp20x.h"
grep -q 'axp313a_regulators' "$SOURCE/drivers/regulator/axp20x-regulator.c"
for patch_file in \
0003-walnutpi-6.1-axp313a-strict-backport.patch \
0004-walnutpi-axp313a-dts.patch \
0005-walnutpi-axp313a-preserve-dldo1.patch \
0006-walnutpi-uwe5622-out-of-tree-build.patch \
0007-walnutpi-enabled-realtek-out-of-tree-build.patch \
0008-walnutpi-rtl8192eu-out-of-tree-build.patch \
0009-walnutpi-rtl8812au-out-of-tree-build.patch \
0010-walnutpi-rtl8812au-phydm-out-of-tree-build.patch \
0011-walnutpi-rtl88x2cs-topdir-out-of-tree-build.patch \
0012-walnutpi-emmc-axp313a-dts.patch \
0013-walnutpi-axp313a-fixed-dcdc-frequency.patch
do
patch -d "$SOURCE" -p1 --forward --batch --fuzz=0 < "$PATCH_ROOT/$patch_file"
done
mkdir -p -- "$BUILD" "$STAGE/boot" "$STAGE/lib"
cp -- "$CONFIG" "$BUILD/.config"
make -C "$SOURCE" O="$BUILD" olddefconfig
"$SOURCE/scripts/config" --file "$BUILD/.config" \
--set-str LOCALVERSION '' \
--disable LOCALVERSION_AUTO \
--set-str SYSTEM_TRUSTED_KEYS '' \
--set-str SYSTEM_REVOCATION_KEYS ''
make -C "$SOURCE" O="$BUILD" olddefconfig
actual_release=$(make -s -C "$SOURCE" O="$BUILD" \
LOCALVERSION="$LOCALVERSION_SUFFIX" kernelrelease)
if [ "$actual_release" != "$RELEASE" ]; then
echo "unexpected kernel release: $actual_release" >&2
exit 1
fi
make -C "$SOURCE" O="$BUILD" LOCALVERSION="$LOCALVERSION_SUFFIX" \
-j"$JOBS" Image dtbs modules
make -C "$SOURCE" O="$BUILD" LOCALVERSION="$LOCALVERSION_SUFFIX" \
modules_install INSTALL_MOD_PATH="$STAGE"
install -m 0644 "$BUILD/arch/arm64/boot/Image" "$STAGE/boot/Image-matrix-axp313a1"
install -m 0644 "$BUILD/arch/arm64/boot/dts/allwinner/sun50i-h616-walnutpi-1b.dtb" \
"$STAGE/boot/sun50i-h616-walnutpi-1b-matrix-axp313a1.dtb"
install -m 0644 "$BUILD/arch/arm64/boot/dts/allwinner/sun50i-h616-walnutpi-1b-emmc.dtb" \
"$STAGE/boot/sun50i-h616-walnutpi-1b-emmc-matrix-axp313a1.dtb"
install -m 0644 "$BUILD/System.map" "$STAGE/boot/System.map-$RELEASE"
install -m 0644 "$BUILD/.config" "$STAGE/boot/config-$RELEASE"
printf '%s\n' "$VENDOR_COMMIT" > "$STAGE/SOURCE_COMMIT"
printf '%s\n' "$RELEASE" > "$STAGE/KERNEL_RELEASE"
(cd "$STAGE" && find boot lib SOURCE_COMMIT KERNEL_RELEASE -type f -print0 | sort -z | xargs -0 sha256sum > SHA256SUMS)
echo "candidate kernel built at $STAGE"
@@ -0,0 +1,79 @@
#!/usr/bin/env python3
"""Create one self-contained browser OTA package on the development computer."""
from __future__ import annotations
import argparse
from datetime import datetime, timezone
import hashlib
from pathlib import Path
import sys
SOURCE_ROOT = Path(__file__).resolve().parents[1]
if str(SOURCE_ROOT) not in sys.path:
sys.path.insert(0, str(SOURCE_ROOT))
from app.ota.package import build_package
from app.ota.versioning import read_software_version
from app.ota.policy import read_policy
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--notes", required=True, help="short release description")
parser.add_argument("--output-root", type=Path)
args = parser.parse_args()
source_root = Path(__file__).resolve().parents[1]
project_root = source_root.parent
wheelhouse = project_root / "发布更新相关" / "其他依赖" / "aarch64-py311"
frpc_bundle = project_root / "发布更新相关" / "其他依赖" / "frp" / "0.71.0" / "linux-arm64"
version = read_software_version(source_root)
policy = read_policy(source_root)
if policy["checkpoints"] and version < type(version).parse(policy["checkpoints"][-1]["version"]):
parser.error("源码包含未发布的软件依赖;请使用 export_release.py ota --version 导出安装节点")
if version.patch != 0:
frpc_bundle = None
output_root = (args.output_root or project_root / "发布更新相关" / "OTA数据包").resolve()
version_dir = output_root / str(version)
if version_dir.exists():
parser.error(f"version output already exists and will not be overwritten: {version_dir}")
version_dir.mkdir(parents=True)
package_path = version_dir / f"matrix-screen-controller-{version}.ota"
created = datetime.now(timezone.utc)
try:
info = build_package(
source_root,
wheelhouse,
package_path,
version=version,
release_notes=args.notes,
created_at=created,
system_dependencies=frpc_bundle,
)
package_sha = hashlib.sha256(package_path.read_bytes()).hexdigest()
readme = (
f"# 奇妙小屏幕控制器 OTA {version}\n\n"
f"- 软件版本:`{version}`\n"
f"- 打包时间:`{info.created_at}`\n"
f"- 更新说明:{args.notes.strip()}\n"
f"- 更新包:`{package_path.name}`\n"
f"- 文件大小:`{package_path.stat().st_size}` bytes\n"
f"- 文件 SHA-256:`{package_sha}`\n"
f"- 载荷 SHA-256:`{info.payload_sha256}`\n\n"
"通过设备网页的“系统设置 → 软件更新”选择 `.ota` 文件。"
"本包是离线全量包,不使用服务器下载、增量、加密或签名。\n"
)
(version_dir / "README.md").write_text(readme, encoding="utf-8", newline="\n")
except BaseException:
package_path.unlink(missing_ok=True)
try:
version_dir.rmdir()
except OSError:
pass
raise
print(version_dir)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,268 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
from datetime import datetime, timezone
import gzip
import hashlib
import json
from pathlib import Path
import shutil
import sys
import tarfile
SOURCE_ROOT = Path(__file__).resolve().parents[1]
if str(SOURCE_ROOT) not in sys.path:
sys.path.insert(0, str(SOURCE_ROOT))
from scripts.fat16_image import Fat16Image
from scripts.image_config import PRODUCT_ID, create_config_file, read_config_file, validate_config
from scripts.debian_closure import IMAGE_DEBIAN_ROOTS, verify_local_closure
from scripts.kernel_artifact import KernelArtifactInfo, verify_kernel_dependency, verify_source_dependency
from scripts.boot_space import DEFAULT_SAFETY_BYTES, calculate_budget, candidate_sizes_from_dependency
EXCLUDED_PARTS = {".venv", "data", "__pycache__", ".pytest_cache", "node_modules"}
NATIVE_BUILD_OUTPUTS = {
"app/display/native/libh618_hub75.so",
"app/display/native/hub75_benchmark",
"app/display/native/hub75_native_test",
"app/display/native/hub75_safeoff",
}
IMAGE_FORMAT_VERSION = 3
BUNDLE_IMAGE_PATH = "/opt/matrix-image-bootstrap/app/MSCBOOT.TGZ"
def _allowed_source(path: Path, root: Path) -> bool:
relative = path.relative_to(root)
if any(part in EXCLUDED_PARTS for part in relative.parts):
return False
if relative.as_posix() in NATIVE_BUILD_OUTPUTS:
return False
return path.suffix not in {".pyc", ".pyo"}
def _add_file(tar: tarfile.TarFile, path: Path, name: str) -> None:
info = tar.gettarinfo(str(path), arcname=name)
info.uid = info.gid = 0
info.uname = info.gname = "root"
info.mtime = 0
info.mode = 0o755 if path.suffix == ".sh" else 0o644
with path.open("rb") as handle:
tar.addfile(info, handle)
def build_bundle(
source: Path,
wheelhouse: Path,
debian: Path,
output: Path,
system_dependencies: Path | None = None,
) -> None:
with output.open("wb") as raw:
with gzip.GzipFile(filename="", mode="wb", fileobj=raw, mtime=0) as compressed:
with tarfile.open(fileobj=compressed, mode="w", format=tarfile.PAX_FORMAT) as tar:
for path in sorted(source.rglob("*"), key=lambda item: item.relative_to(source).as_posix()):
if path.is_file() and _allowed_source(path, source):
_add_file(tar, path, f"project/核桃派软件源代码/{path.relative_to(source).as_posix()}")
for root, archive_root in (
(wheelhouse, "project/离线依赖/其他依赖/aarch64-py311"),
(debian, "project/离线依赖/其他依赖/debian12-aarch64"),
):
for path in sorted(root.rglob("*"), key=lambda item: item.relative_to(root).as_posix()):
if path.is_file():
_add_file(tar, path, f"{archive_root}/{path.relative_to(root).as_posix()}")
if system_dependencies is not None:
for path in sorted(system_dependencies.rglob("*"), key=lambda item: item.relative_to(system_dependencies).as_posix()):
if path.is_file():
_add_file(
tar,
path,
f"project/离线依赖/其他依赖/frp/0.71.0/linux-arm64/{path.relative_to(system_dependencies).as_posix()}",
)
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(4 * 1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def image_metadata(
version: str,
bundle_bytes: int,
bundle_sha256: str,
kernel: KernelArtifactInfo,
boot_required_bytes: int,
boot_safety_bytes: int = DEFAULT_SAFETY_BYTES,
) -> dict:
return {
"format_version": IMAGE_FORMAT_VERSION,
"product": PRODUCT_ID,
"software_version": version,
"bundle_path": BUNDLE_IMAGE_PATH,
"bundle_bytes": bundle_bytes,
"bundle_sha256": bundle_sha256,
"kernel_release": kernel.kernel_release,
"kernel_artifact": kernel.artifact,
"kernel_artifact_bytes": kernel.artifact_bytes,
"kernel_artifact_sha256": kernel.artifact_sha256,
"kernel_unpacked_file_bytes": kernel.unpacked_file_bytes,
"boot_required_bytes": boot_required_bytes,
"boot_safety_bytes": boot_safety_bytes,
}
def build_image(
base_image: Path,
source: Path,
wheelhouse: Path,
debian: Path,
kernel: Path,
kernel_source: Path,
config: dict | bytes,
output: Path,
bootstrap_bundle: Path,
system_dependencies: Path | None = None,
) -> dict:
base_image = Path(base_image).resolve()
source = Path(source).resolve()
wheelhouse = Path(wheelhouse).resolve()
debian = Path(debian).resolve()
kernel = Path(kernel).resolve()
kernel_source = Path(kernel_source).resolve()
output = Path(output).resolve()
bootstrap_bundle = Path(bootstrap_bundle).resolve()
system_dependencies = Path(system_dependencies).resolve() if system_dependencies is not None else None
if system_dependencies is None:
raise ValueError("the verified frpc system dependency bundle is required")
if isinstance(config, bytes):
config_bytes = config
config, _, _ = read_config_file(config_bytes)
config = validate_config(config)
else:
config = validate_config(config)
config_bytes = create_config_file(config)
version = config["software_version"]
if (source / "VERSION").read_text(encoding="utf-8").strip() != version:
raise ValueError("image configuration version does not match source VERSION")
for manifest in (
wheelhouse / "SHA256SUMS",
debian / "SHA256SUMS",
debian / "BASE_IMAGE_PACKAGES.tsv",
system_dependencies / "SHA256SUMS",
):
if not manifest.is_file():
raise FileNotFoundError(manifest)
verify_local_closure(debian, debian / "BASE_IMAGE_PACKAGES.tsv", list(IMAGE_DEBIAN_ROOTS))
kernel_info = verify_kernel_dependency(kernel)
verify_source_dependency(kernel_source)
if output.exists() or bootstrap_bundle.exists():
raise FileExistsError(output if output.exists() else bootstrap_bundle)
output.parent.mkdir(parents=True, exist_ok=True)
bootstrap_bundle.parent.mkdir(parents=True, exist_ok=True)
try:
build_bundle(source, wheelhouse, debian, bootstrap_bundle, system_dependencies)
bundle_digest = sha256_file(bootstrap_bundle)
shutil.copyfile(base_image, output)
except BaseException:
output.unlink(missing_ok=True)
bootstrap_bundle.unlink(missing_ok=True)
raise
try:
firstboot = (source / "scripts" / "image_firstboot.sh").read_bytes()
with Fat16Image(output, writable=True) as image:
image.write_file("MSCCFG.BIN", config_bytes, contiguous=True)
image.write_file("MSCINIT", firstboot)
with Fat16Image(output) as image:
boot_budget = calculate_budget(
candidate_sizes=candidate_sizes_from_dependency(kernel),
boot_cmd=image.read_file("BOOT.CMD"),
boot_scr=image.read_file("BOOT.SCR"),
cluster_bytes=image.cluster_bytes,
available_bytes=image.free_bytes(),
)
if boot_budget.available_bytes < boot_budget.total_bytes:
raise RuntimeError(
"FAT boot partition lacks safe candidate-kernel space: "
f"available={boot_budget.available_bytes}, required={boot_budget.required_bytes}, "
f"safety={boot_budget.safety_bytes}, total={boot_budget.total_bytes}"
)
meta = image_metadata(
version,
bootstrap_bundle.stat().st_size,
bundle_digest,
kernel_info,
boot_budget.required_bytes,
boot_budget.safety_bytes,
)
rendered_meta = (
json.dumps(meta, ensure_ascii=False, sort_keys=True, indent=2) + "\n"
).encode("utf-8")
with Fat16Image(output, writable=True) as image:
image.write_file("MSCMETA.JSN", rendered_meta)
with Fat16Image(output) as image:
if image.read_file("MSCCFG.BIN") != config_bytes:
raise RuntimeError("image configuration read-back failed")
if image.find("MSCBOOT.TGZ") is not None:
raise RuntimeError("FAT boot partition unexpectedly contains the application bundle")
if image.read_file("MSCMETA.JSN") != rendered_meta:
raise RuntimeError("image metadata read-back failed")
if image.read_file("MSCINIT") != firstboot:
raise RuntimeError("image first-boot program read-back failed")
final_budget = calculate_budget(
candidate_sizes=candidate_sizes_from_dependency(kernel),
boot_cmd=image.read_file("BOOT.CMD"),
boot_scr=image.read_file("BOOT.SCR"),
cluster_bytes=image.cluster_bytes,
available_bytes=image.free_bytes(),
)
if final_budget.available_bytes < final_budget.total_bytes:
raise RuntimeError("final FAT boot partition no longer satisfies the recorded space budget")
except BaseException:
output.unlink(missing_ok=True)
bootstrap_bundle.unlink(missing_ok=True)
raise
return {
**meta,
"created_at": datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds"),
"image_bytes": output.stat().st_size,
"image_sha256": sha256_file(output),
}
def main() -> int:
parser = argparse.ArgumentParser(description="Build a Rufus-writable WalnutPi controller image")
parser.add_argument("--base-image", type=Path, required=True)
parser.add_argument("--source", type=Path, required=True)
parser.add_argument("--wheelhouse", type=Path, required=True)
parser.add_argument("--debian", type=Path, required=True)
parser.add_argument("--kernel", type=Path, required=True)
parser.add_argument("--kernel-source", type=Path, required=True)
parser.add_argument("--config", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--bootstrap-bundle-output", type=Path, required=True)
parser.add_argument("--frpc-bundle", type=Path, required=True)
args = parser.parse_args()
config = json.loads(args.config.read_text(encoding="utf-8"))
info = build_image(
args.base_image,
args.source,
args.wheelhouse,
args.debian,
args.kernel,
args.kernel_source,
config,
args.output,
args.bootstrap_bundle_output,
args.frpc_bundle,
)
print(json.dumps(info, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,305 @@
from __future__ import annotations
import argparse
import hashlib
import json
import re
from dataclasses import dataclass, field
from html.parser import HTMLParser
from pathlib import Path
from typing import Any, Iterable
SOURCE_ROOT = Path(__file__).resolve().parents[1]
STATIC_ROOT = SOURCE_ROOT / "app" / "static"
OUTPUT_PATH = STATIC_ROOT / "ui-copy.json"
DYNAMIC_PATH = STATIC_ROOT / "ui-copy-dynamic.json"
TEXT_ATTRIBUTES = ("aria-label", "placeholder", "data-unavailable-reason", "title")
ANCHOR_TAGS = {"article", "section", "header", "main", "form", "fieldset", "div"}
IGNORED_TAGS = {"script", "style", "svg", "path", "noscript"}
PLACEHOLDER_PATTERN = re.compile(r"\{([a-zA-Z][a-zA-Z0-9_]*)\}")
FUNCTIONAL_VALUE_PATTERN = re.compile(r"^[+\-]?(?:\d+(?:\.\d+)?|#[0-9a-fA-F]{3,8})(?:\s*(?:%|°|Hz|ms|px|V))?$")
@dataclass
class HtmlNode:
tag: str
attrs: dict[str, str]
parent: "HtmlNode | None" = None
children: list["HtmlNode | str"] = field(default_factory=list)
class TreeParser(HTMLParser):
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
self.root = HtmlNode("document", {})
self.stack = [self.root]
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
node = HtmlNode(tag, {key: value or "" for key, value in attrs}, self.stack[-1])
self.stack[-1].children.append(node)
if tag not in {"area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"}:
self.stack.append(node)
def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
node = HtmlNode(tag, {key: value or "" for key, value in attrs}, self.stack[-1])
self.stack[-1].children.append(node)
def handle_endtag(self, tag: str) -> None:
for index in range(len(self.stack) - 1, 0, -1):
if self.stack[index].tag == tag:
del self.stack[index:]
return
def handle_data(self, data: str) -> None:
self.stack[-1].children.append(data)
def normalized_text(value: str) -> str:
return re.sub(r"\s+", " ", value).strip()
def stable_id(prefix: str, *parts: object) -> str:
raw = "\0".join(str(part) for part in parts)
return f"{prefix}.{hashlib.sha256(raw.encode('utf-8')).hexdigest()[:16]}"
def iter_nodes(node: HtmlNode) -> Iterable[HtmlNode]:
for child in node.children:
if isinstance(child, HtmlNode):
yield child
yield from iter_nodes(child)
def nearest_scope(node: HtmlNode) -> str:
current: HtmlNode | None = node
while current:
identifier = current.attrs.get("id", "")
if identifier.startswith("workspace-"):
return identifier.removeprefix("workspace-")
if current.tag == "dialog" and identifier:
return f"dialog:{identifier}"
current = current.parent
return "global"
def copy_ignored(node: HtmlNode) -> bool:
current: HtmlNode | None = node
while current:
if "data-ui-copy-ignore" in current.attrs or "data-ui-copy-value" in current.attrs:
return True
current = current.parent
return False
def selector_for(node: HtmlNode) -> str:
if identifier := node.attrs.get("id"):
return f"#{identifier}"
parts: list[str] = []
current: HtmlNode | None = node
while current and current.tag != "document":
if identifier := current.attrs.get("id"):
parts.append(f"#{identifier}")
break
parent = current.parent
if parent is None:
parts.append(current.tag)
break
siblings = [child for child in parent.children if isinstance(child, HtmlNode) and child.tag == current.tag]
position = next(index for index, sibling in enumerate(siblings, 1) if sibling is current)
parts.append(f"{current.tag}:nth-of-type({position})")
current = parent
return " > ".join(reversed(parts))
def placeholders(text: str) -> list[str]:
return PLACEHOLDER_PATTERN.findall(text)
def add_item(items: dict[str, dict[str, Any]], item_id: str, *, text: str, scope: str,
source: str, kind: str, render: dict[str, Any] | None = None) -> None:
if not text or item_id in items:
return
entry: dict[str, Any] = {
"text": text,
"scope": scope,
"source": source,
"kind": kind,
"placeholders": placeholders(text),
}
if render:
entry["render"] = render
items[item_id] = entry
def collect_html(html_path: Path, items: dict[str, dict[str, Any]], anchors: dict[str, dict[str, str]]) -> None:
parser = TreeParser()
parser.feed(html_path.read_text(encoding="utf-8"))
for node in iter_nodes(parser.root):
if node.tag in IGNORED_TAGS or copy_ignored(node):
continue
selector = selector_for(node)
scope = nearest_scope(node)
if node.tag in ANCHOR_TAGS:
anchor_id = stable_id("anchor", selector)
anchors[anchor_id] = {"selector": selector, "scope": scope, "tag": node.tag}
text_position = 0
default_hidden_text = normalized_text(node.attrs.get("data-ui-copy-default-text", ""))
if default_hidden_text:
item_id = stable_id("copy", "index.html", selector, "text", text_position)
add_item(
items,
item_id,
text=default_hidden_text,
scope=scope,
source=f"index.html::{selector}::text[{text_position}]",
kind="html_text",
render={
"type": "static_text",
"selector": selector,
"text_index": text_position,
"default_hidden": True,
},
)
text_position += 1
for child in node.children:
if not isinstance(child, str):
continue
text = normalized_text(child)
if not text or text == "-" or FUNCTIONAL_VALUE_PATTERN.fullmatch(text):
continue
item_id = stable_id("copy", "index.html", selector, "text", text_position)
add_item(
items,
item_id,
text=text,
scope=scope,
source=f"index.html::{selector}::text[{text_position}]",
kind="html_text",
render={"type": "static_text", "selector": selector, "text_index": text_position},
)
text_position += 1
for attribute in TEXT_ATTRIBUTES:
text = normalized_text(node.attrs.get(attribute, ""))
if not text:
continue
item_id = stable_id("copy", "index.html", selector, "attribute", attribute)
add_item(
items,
item_id,
text=text,
scope=scope,
source=f"index.html::{selector}::@{attribute}",
kind="html_attribute",
render={"type": "attribute", "selector": selector, "attribute": attribute},
)
def scan_javascript_literals(source: str) -> Iterable[tuple[int, str, str]]:
index = 0
while index < len(source):
quote = source[index]
if quote not in {'"', "'", "`"}:
index += 1
continue
start = index
index += 1
value: list[str] = []
expression_depth = 0
placeholder_number = 0
while index < len(source):
char = source[index]
if char == "\\":
if index + 1 < len(source):
value.extend((char, source[index + 1]))
index += 2
continue
if quote == "`" and char == "$" and index + 1 < len(source) and source[index + 1] == "{":
placeholder_number += 1
value.append(f"{{value{placeholder_number}}}")
index += 2
expression_depth = 1
inner_quote: str | None = None
while index < len(source) and expression_depth:
current = source[index]
if inner_quote:
if current == "\\":
index += 2
continue
if current == inner_quote:
inner_quote = None
elif current in {'"', "'", "`"}:
inner_quote = current
elif current == "{":
expression_depth += 1
elif current == "}":
expression_depth -= 1
index += 1
continue
if char == quote:
index += 1
raw = "".join(value)
raw = raw.replace("\\n", "\n").replace("\\r", "\r").replace("\\t", "\t")
raw = raw.replace(f"\\{quote}", quote).replace("\\\\", "\\")
yield start, quote, raw
break
value.append(char)
index += 1
def collect_dynamic(items: dict[str, dict[str, Any]]) -> None:
raw = json.loads(DYNAMIC_PATH.read_text(encoding="utf-8"))
if not isinstance(raw, dict):
raise ValueError("ui-copy-dynamic.json must be an object")
for item_id, value in raw.items():
if not isinstance(value, dict) or set(value) - {"text", "scope", "source", "default_hidden"}:
raise ValueError(f"invalid dynamic UI copy item: {item_id}")
if not {"text", "scope", "source"} <= set(value) or type(value.get("default_hidden", False)) is not bool:
raise ValueError(f"invalid dynamic UI copy item: {item_id}")
add_item(
items,
item_id,
text=normalized_text(value["text"]),
scope=value["scope"],
source=value["source"],
kind="dynamic_template",
render={"type": "dynamic_template", **({"default_hidden": True} if value.get("default_hidden") else {})},
)
def build_catalog() -> dict[str, Any]:
items: dict[str, dict[str, Any]] = {}
anchors: dict[str, dict[str, str]] = {}
collect_html(STATIC_ROOT / "index.html", items, anchors)
collect_dynamic(items)
return {
"schema_version": 2,
"items": dict(sorted(items.items())),
"anchors": dict(sorted(anchors.items())),
"insertions": [],
}
def canonical_bytes(catalog: dict[str, Any]) -> bytes:
return (json.dumps(catalog, ensure_ascii=False, indent=2, sort_keys=True) + "\n").encode("utf-8")
def main() -> int:
parser = argparse.ArgumentParser(description="Build or verify the UI copy catalog")
parser.add_argument("--check", action="store_true", help="fail when ui-copy.json is not current")
args = parser.parse_args()
content = canonical_bytes(build_catalog())
if args.check:
if not OUTPUT_PATH.is_file() or OUTPUT_PATH.read_bytes() != content:
raise SystemExit("ui-copy.json is out of date; run scripts/build_ui_copy_catalog.py")
print("ui-copy.json is current")
return 0
OUTPUT_PATH.write_bytes(content)
print(f"wrote {OUTPUT_PATH} ({len(json.loads(content)['items'])} items)")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,104 @@
#!/bin/sh
set -eu
OUTPUT=
while [ "$#" -gt 0 ]; do
case "$1" in
--output) OUTPUT=$2; shift 2 ;;
*) echo "unknown argument: $1" >&2; exit 2 ;;
esac
done
if [ "$(id -u)" -ne 0 ]; then
echo "state capture must run as root" >&2
exit 1
fi
if [ -z "$OUTPUT" ]; then
echo "usage: capture_axp313a_state.sh --output NEW_DIR" >&2
exit 2
fi
parent=$(dirname -- "$OUTPUT")
mkdir -p -- "$parent"
parent=$(CDPATH= cd -- "$parent" && pwd)
OUTPUT=$parent/$(basename -- "$OUTPUT")
case "$OUTPUT" in
/|/boot|/opt|/var|/var/lib|/var/lib/matrix-screen-controller|/run|/etc)
echo "unsafe state output directory: $OUTPUT" >&2
exit 1
;;
esac
if [ -e "$OUTPUT" ]; then
echo "state output directory must not exist: $OUTPUT" >&2
exit 1
fi
install -d -m 0700 -- "$OUTPUT"
uname -a > "$OUTPUT/uname.txt"
cat /proc/cmdline > "$OUTPUT/cmdline.txt"
(cd /boot && find . -maxdepth 1 -type f -print0 | sort -z | xargs -0 sha256sum) \
> "$OUTPUT/boot.sha256"
(cd /var/lib && find matrix-screen-controller -type f -print0 | sort -z | xargs -0 sha256sum) \
> "$OUTPUT/persistent.sha256"
if [ -d /sys/kernel/debug/regmap ]; then
for registers in /sys/kernel/debug/regmap/*-0036/registers; do
[ -f "$registers" ] || continue
regmap_name=$(basename -- "$(dirname -- "$registers")")
cp -- "$registers" "$OUTPUT/pmic-$regmap_name-registers.txt"
done
fi
: > "$OUTPUT/regulators.txt"
for regulator in /sys/class/regulator/regulator.*; do
[ -d "$regulator" ] || continue
printf '[%s]\n' "$(basename -- "$regulator")" >> "$OUTPUT/regulators.txt"
for field in name microvolts state status; do
if [ -r "$regulator/$field" ]; then
if value=$(cat "$regulator/$field" 2>/dev/null); then
printf '%s=%s\n' "$field" "$value" >> "$OUTPUT/regulators.txt"
else
printf '%s=<unavailable>\n' "$field" >> "$OUTPUT/regulators.txt"
fi
fi
done
done
: > "$OUTPUT/cpufreq.txt"
for policy in /sys/devices/system/cpu/cpufreq/policy*; do
[ -d "$policy" ] || continue
printf '[%s]\n' "$(basename -- "$policy")" >> "$OUTPUT/cpufreq.txt"
for field in affected_cpus related_cpus scaling_available_frequencies \
scaling_available_governors scaling_cur_freq scaling_governor; do
if [ -r "$policy/$field" ]; then
if value=$(cat "$policy/$field" 2>/dev/null); then
printf '%s=%s\n' "$field" "$value" >> "$OUTPUT/cpufreq.txt"
else
printf '%s=<unavailable>\n' "$field" >> "$OUTPUT/cpufreq.txt"
fi
fi
done
done
if [ -r /sys/kernel/debug/devices_deferred ]; then
cp -- /sys/kernel/debug/devices_deferred "$OUTPUT/devices_deferred.txt"
fi
: > "$OUTPUT/gpu.txt"
for devfreq in /sys/class/devfreq/*; do
[ -d "$devfreq" ] || continue
case "$(basename -- "$devfreq")" in
*gpu*|*mali*)
printf '[%s]\n' "$(basename -- "$devfreq")" >> "$OUTPUT/gpu.txt"
for field in available_frequencies cur_freq governor min_freq max_freq; do
if [ -r "$devfreq/$field" ]; then
if value=$(cat "$devfreq/$field" 2>/dev/null); then
printf '%s=%s\n' "$field" "$value" >> "$OUTPUT/gpu.txt"
else
printf '%s=<unavailable>\n' "$field" >> "$OUTPUT/gpu.txt"
fi
fi
done
;;
esac
done
sync
echo "state captured at $OUTPUT"
@@ -0,0 +1,284 @@
from __future__ import annotations
import argparse
import ipaddress
import os
import re
import subprocess
import sys
from pathlib import Path, PurePosixPath
SCRIPT_PATH = Path(__file__).resolve()
WORKSPACE_ROOT = SCRIPT_PATH.parents[2]
PRIVATE_CREDENTIAL = PurePosixPath("测试相关资料/核桃派的用户名和密码和ip/用户名密码ip.txt")
EXAMPLE_CREDENTIAL = PurePosixPath(
"测试相关资料/核桃派的用户名和密码和ip/用户名密码ip.example.txt"
)
BINARY_EXTENSIONS = {
".deb",
".dll",
".docx",
".exe",
".gif",
".gz",
".img",
".jpeg",
".jpg",
".otf",
".ota",
".pdf",
".png",
".rar",
".so",
".ttf",
".whl",
".woff",
".woff2",
".zip",
}
PRIVATE_IP_ALLOWED_PREFIXES = (
"整体开发需求/",
"测试相关资料/如何测试/",
"核桃派软件源代码/",
"发布更新相关/其他依赖/",
)
SAFE_SECRET_VALUES = {
"changeme",
"example",
"example123",
"fake",
"fake-secret",
"password",
"secret123",
"test",
"test-password",
}
# The password is a high-confidence private marker. Addresses, usernames and
# hostnames are checked structurally because common fixture values also occur in
# provisioning source and tests.
CURRENT_DEVICE_KEYS = ("密码",)
PUBLIC_IMAGE_KEYS = (
"镜像默认用户名",
"镜像默认账户密码",
"镜像默认WiFi SSID",
"镜像默认WiFi密码",
)
class HygieneError(RuntimeError):
"""Repository state cannot be audited safely."""
def _git(*arguments: str, check: bool = True) -> subprocess.CompletedProcess[bytes]:
return subprocess.run(
("git", *arguments),
cwd=WORKSPACE_ROOT,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=check,
)
def _decode_paths(payload: bytes) -> list[PurePosixPath]:
return [
PurePosixPath(item.decode("utf-8", errors="surrogateescape"))
for item in payload.split(b"\0")
if item
]
def candidate_paths(staged: bool) -> list[PurePosixPath]:
if staged:
result = _git("diff", "--cached", "--name-only", "--diff-filter=ACMR", "-z")
return _decode_paths(result.stdout)
tracked = _decode_paths(_git("ls-files", "-z").stdout)
untracked = _decode_paths(_git("ls-files", "--others", "--exclude-standard", "-z").stdout)
return sorted(set((*tracked, *untracked)), key=str)
def _is_binary(path: Path) -> bool:
if path.suffix.casefold() in BINARY_EXTENSIONS:
return True
try:
return b"\0" in path.read_bytes()[:8192]
except OSError as error:
raise HygieneError(f"无法读取候选文件:{path.relative_to(WORKSPACE_ROOT)}") from error
def _host_text_patterns() -> list[re.Pattern[str]]:
windows_prefix = r"[A-Za-z]:[\\/]" + r"(?:Users|Documents and Settings)[\\/]"
mac_prefix = r"/" + r"Users/[A-Za-z0-9._-]+/"
linux_home = r"/" + r"home/[A-Za-z0-9._-]+/"
patterns = [re.compile(windows_prefix, re.IGNORECASE), re.compile(mac_prefix), re.compile(linux_home)]
for value in (Path.home().name, os.environ.get("COMPUTERNAME", "")):
if value and len(value) >= 4:
patterns.append(re.compile(re.escape(value), re.IGNORECASE))
return patterns
def _private_value_markers() -> tuple[str, ...]:
path = WORKSPACE_ROOT.joinpath(*PRIVATE_CREDENTIAL.parts)
if not path.is_file():
return ()
fields: dict[str, str] = {}
for raw_line in path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#"):
continue
separator = ":" if ":" in line else ":" if ":" in line else None
if separator is None:
continue
key, value = (part.strip() for part in line.split(separator, 1))
fields[key] = value
public_values = {fields.get(key, "") for key in PUBLIC_IMAGE_KEYS}
return tuple(
value
for key in CURRENT_DEVICE_KEYS
if len(value := fields.get(key, "")) >= 4 and value not in public_values
)
def _binary_markers(private_values: tuple[str, ...] = ()) -> list[bytes]:
markers: list[bytes] = []
host_values = {
str(Path.home()),
str(WORKSPACE_ROOT),
Path.home().name,
os.environ.get("COMPUTERNAME", ""),
}
for value in (*host_values, *private_values):
if value and len(value) >= 4:
variants = {value, value.replace("\\", "/")}
for variant in variants:
markers.extend((variant.encode("utf-8"), variant.encode("utf-16le")))
return markers
def _binary_contains_forbidden_marker(path: Path, private_values: tuple[str, ...]) -> bool:
markers = _binary_markers(private_values)
overlap = max(map(len, markers)) - 1
tail = b""
with path.open("rb") as stream:
while chunk := stream.read(8 * 1024 * 1024):
sample = tail + chunk
lowered_sample = sample.lower()
if any(marker.lower() in lowered_sample for marker in markers):
return True
tail = sample[-overlap:] if overlap else b""
return False
def _text_issues(
relative: PurePosixPath,
text: str,
private_values: tuple[str, ...] = (),
) -> list[str]:
issues: list[str] = []
if any(pattern.search(text) for pattern in _host_text_patterns()):
issues.append("包含开发电脑专属路径、用户名或主机名")
if any(value in text for value in private_values):
issues.append("包含当前设备私有凭据值")
key_header = re.compile("-----BEGIN " + r"(?:OPENSSH |RSA |EC |DSA )?PRIVATE KEY-----")
if key_header.search(text):
issues.append("包含私钥正文")
relative_string = relative.as_posix()
if not (
relative_string.startswith("核桃派软件源代码/tests/")
or PurePosixPath(relative_string).name.startswith("test_")
or relative == EXAMPLE_CREDENTIAL
):
assignment = re.compile(
r"(?i)(?:password|passwd|token|secret|api[_-]?key|密码)\s*[:=]\s*[\"']([^\"']{4,})[\"']"
)
for match in assignment.finditer(text):
if match.group(1).casefold() not in SAFE_SECRET_VALUES:
issues.append("包含疑似硬编码秘密")
break
if not (
relative_string.startswith(PRIVATE_IP_ALLOWED_PREFIXES)
or "/tests/" in f"/{relative_string}"
):
for token in re.findall(r"(?<![0-9])(?:[0-9]{1,3}\.){3}[0-9]{1,3}(?![0-9])", text):
try:
address = ipaddress.ip_address(token)
except ValueError:
continue
if address.is_private and not address.is_loopback and not address.is_unspecified:
issues.append("归档或普通文档包含私有 IPv4 地址")
break
return issues
def audit_paths(paths: list[PurePosixPath], *, scan_binary: bool) -> list[tuple[str, str]]:
findings: list[tuple[str, str]] = []
private_values = _private_value_markers()
for relative in paths:
if relative == PRIVATE_CREDENTIAL:
findings.append((relative.as_posix(), "真实凭据进入 Git 候选集合"))
continue
path = WORKSPACE_ROOT.joinpath(*relative.parts)
if not path.is_file():
continue
if _is_binary(path):
if scan_binary and _binary_contains_forbidden_marker(path, private_values):
findings.append((relative.as_posix(), "二进制包含开发电脑标识、主目录路径或当前设备秘密"))
continue
try:
text = path.read_text(encoding="utf-8")
except UnicodeDecodeError:
findings.append((relative.as_posix(), "文本候选不是有效 UTF-8"))
continue
findings.extend(
(relative.as_posix(), issue) for issue in _text_issues(relative, text, private_values)
)
return findings
def _check_repository_contract(paths: list[PurePosixPath], staged: bool) -> list[tuple[str, str]]:
findings: list[tuple[str, str]] = []
ignored = _git("check-ignore", "--quiet", "--", PRIVATE_CREDENTIAL.as_posix(), check=False)
if ignored.returncode != 0:
findings.append((PRIVATE_CREDENTIAL.as_posix(), "真实凭据未被 .gitignore 精确排除"))
if staged:
example_in_index = _git("cat-file", "-e", f":{EXAMPLE_CREDENTIAL.as_posix()}", check=False)
if example_in_index.returncode != 0:
findings.append((EXAMPLE_CREDENTIAL.as_posix(), "示例凭据没有进入本次提交"))
elif EXAMPLE_CREDENTIAL not in paths:
findings.append((EXAMPLE_CREDENTIAL.as_posix(), "示例凭据不在 Git 候选集合"))
return findings
def main() -> int:
parser = argparse.ArgumentParser(description="检查 Git 候选文件中的凭据、主机路径和秘密")
parser.add_argument("--staged", action="store_true", help="只检查已暂存的新建或修改文件")
parser.add_argument(
"--skip-binary-scan",
action="store_true",
help="跳过大体积二进制字节扫描,仅供快速诊断",
)
args = parser.parse_args()
try:
paths = candidate_paths(args.staged)
findings = _check_repository_contract(paths, args.staged)
findings.extend(audit_paths(paths, scan_binary=not args.skip_binary_scan))
except (HygieneError, OSError, subprocess.CalledProcessError) as error:
print(f"仓库卫生检查无法完成:{error}", file=sys.stderr)
return 2
if findings:
print("仓库卫生检查失败;以下输出只包含文件路径和问题类型:", file=sys.stderr)
for path, issue in findings:
print(f"- {path}: {issue}", file=sys.stderr)
return 1
print(f"仓库卫生检查通过:已检查 {len(paths)} 个 Git 候选文件,未输出任何秘密值。")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,374 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
from collections import deque
from dataclasses import dataclass
import hashlib
from io import BytesIO
import json
import lzma
from pathlib import Path
import re
import tarfile
from urllib.parse import urljoin
from urllib.request import urlopen
PACKAGE_NAME = re.compile(r"^([a-z0-9][a-z0-9+.-]*(?::(?:any|native|arm64))?)")
ARCH_FILTER = re.compile(r"\[([^]]+)]")
VERSION_FILTER = re.compile(r"\((=|>=|<=|>>|<<)\s*([^)\s]+)\)")
IMAGE_DEBIAN_ROOTS = ("ffmpeg", "libheif-examples", "python3.11-venv")
@dataclass(frozen=True)
class Dependency:
name: str
operator: str = ""
version: str = ""
@dataclass(frozen=True)
class PackageRecord:
name: str
version: str
architecture: str
dependencies: tuple[tuple[Dependency, ...], ...]
provides: tuple[str, ...]
filename: str = ""
sha256: str = ""
size: int = 0
repository_base: str = ""
local_path: Path | None = None
def normalize_package_name(value: str) -> str:
value = value.strip()
if ":" in value:
name, qualifier = value.rsplit(":", 1)
if qualifier in {"any", "native", "arm64"}:
return name
return value
def _arch_applies(value: str, architecture: str = "arm64") -> bool:
match = ARCH_FILTER.search(value)
if not match:
return True
filters = match.group(1).split()
positives = {item for item in filters if not item.startswith("!")}
negatives = {item[1:] for item in filters if item.startswith("!")}
return architecture not in negatives and (not positives or architecture in positives)
def parse_dependency_field(value: str) -> tuple[tuple[Dependency, ...], ...]:
groups: list[tuple[Dependency, ...]] = []
for raw_group in value.split(","):
alternatives: list[Dependency] = []
for raw_alternative in raw_group.split("|"):
if not _arch_applies(raw_alternative):
continue
match = PACKAGE_NAME.match(raw_alternative.strip())
if not match:
continue
version_match = VERSION_FILTER.search(raw_alternative)
alternatives.append(
Dependency(
normalize_package_name(match.group(1)),
version_match.group(1) if version_match else "",
version_match.group(2) if version_match else "",
)
)
if alternatives:
groups.append(tuple(alternatives))
return tuple(groups)
def parse_control_stanzas(text: str) -> list[dict[str, str]]:
stanzas: list[dict[str, str]] = []
current: dict[str, str] = {}
current_key = ""
for line in text.splitlines() + [""]:
if not line:
if current:
stanzas.append(current)
current = {}
current_key = ""
continue
if line[0].isspace() and current_key:
current[current_key] += " " + line.strip()
continue
key, separator, value = line.partition(":")
if separator:
current_key = key
current[key] = value.strip()
return stanzas
def record_from_fields(
fields: dict[str, str], *, repository_base: str = "", local_path: Path | None = None
) -> PackageRecord:
dependencies = ", ".join(
value for key in ("Pre-Depends", "Depends") if (value := fields.get(key, ""))
)
provides = tuple(
normalize_package_name(item.strip().split()[0])
for item in fields.get("Provides", "").split(",")
if item.strip()
)
return PackageRecord(
name=normalize_package_name(fields["Package"]),
version=fields["Version"],
architecture=fields.get("Architecture", "arm64"),
dependencies=parse_dependency_field(dependencies),
provides=provides,
filename=fields.get("Filename", ""),
sha256=fields.get("SHA256", "").lower(),
size=int(fields.get("Size", "0")),
repository_base=repository_base,
local_path=local_path,
)
def load_package_index(path: Path, repository_base: str) -> dict[str, PackageRecord]:
path = Path(path)
if path.suffix == ".xz":
with lzma.open(path, "rt", encoding="utf-8") as handle:
text = handle.read()
else:
text = path.read_text(encoding="utf-8")
records: dict[str, PackageRecord] = {}
for fields in parse_control_stanzas(text):
if fields.get("Architecture") not in {"arm64", "all"}:
continue
record = record_from_fields(fields, repository_base=repository_base)
records.setdefault(record.name, record)
return records
def _read_ar_member(path: Path, wanted_prefix: str) -> bytes:
data = Path(path).read_bytes()
if not data.startswith(b"!<arch>\n"):
raise ValueError(f"not a Debian ar archive: {path}")
offset = 8
while offset + 60 <= len(data):
header = data[offset : offset + 60]
name = header[:16].decode("ascii").strip().rstrip("/")
size = int(header[48:58].decode("ascii").strip())
start = offset + 60
end = start + size
if name.startswith(wanted_prefix):
return data[start:end]
offset = end + (size % 2)
raise ValueError(f"{wanted_prefix} is missing from {path}")
def read_deb_control(path: Path) -> dict[str, str]:
control_archive = _read_ar_member(path, "control.tar")
with tarfile.open(fileobj=BytesIO(control_archive), mode="r:*") as archive:
member = next(
(item for item in archive.getmembers() if item.name.lstrip("./") == "control"),
None,
)
if member is None:
raise ValueError(f"control file is missing from {path}")
handle = archive.extractfile(member)
if handle is None:
raise ValueError(f"control file cannot be read from {path}")
stanzas = parse_control_stanzas(handle.read().decode("utf-8"))
if len(stanzas) != 1:
raise ValueError(f"unexpected control data in {path}")
return stanzas[0]
def load_local_packages(directory: Path) -> dict[str, PackageRecord]:
records: dict[str, PackageRecord] = {}
for path in sorted(Path(directory).glob("*.deb")):
record = record_from_fields(read_deb_control(path), local_path=path.resolve())
if record.name in records:
raise ValueError(f"duplicate package {record.name} in {directory}")
records[record.name] = record
return records
def load_installed_inventory(path: Path) -> dict[str, str]:
installed: dict[str, str] = {}
for number, line in enumerate(Path(path).read_text(encoding="utf-8").splitlines(), 1):
if not line:
continue
parts = line.split("\t")
if len(parts) != 3:
raise ValueError(f"invalid inventory line {number}")
status, name, version = parts
if status == "ii ":
installed[normalize_package_name(name)] = version
return installed
def _exact_version_matches(dependency: Dependency, version: str) -> bool:
return dependency.operator != "=" or dependency.version == version
def resolve_closure(
roots: list[str],
installed: dict[str, str],
local: dict[str, PackageRecord],
available: dict[str, PackageRecord],
) -> tuple[dict[str, PackageRecord], list[str]]:
installed = dict(installed)
for name in local:
installed.pop(name, None)
providers: dict[str, list[PackageRecord]] = {}
for record in [*local.values(), *available.values()]:
for provided in record.provides:
providers.setdefault(provided, []).append(record)
selected: dict[str, PackageRecord] = {}
queue: deque[str] = deque(normalize_package_name(item) for item in roots)
unresolved: list[str] = []
def candidate_for(dependency: Dependency) -> PackageRecord | None:
direct = local.get(dependency.name) or available.get(dependency.name)
if direct and _exact_version_matches(dependency, direct.version):
return direct
for provider in providers.get(dependency.name, []):
if _exact_version_matches(dependency, provider.version):
return provider
return None
while queue:
name = queue.popleft()
if name in selected:
continue
record = local.get(name) or available.get(name)
if record is None:
unresolved.append(f"required package is unavailable: {name}")
continue
selected[name] = record
for group in record.dependencies:
if any(
dependency.name in installed
and _exact_version_matches(dependency, installed[dependency.name])
for dependency in group
):
continue
if any(
dependency.name in selected
and _exact_version_matches(dependency, selected[dependency.name].version)
for dependency in group
):
continue
choice = next((candidate_for(dependency) for dependency in group if candidate_for(dependency)), None)
if choice is None:
unresolved.append(
f"{record.name} cannot satisfy: "
+ " | ".join(
f"{item.name} {item.operator} {item.version}".strip() for item in group
)
)
continue
queue.append(choice.name)
return selected, sorted(set(unresolved))
def verify_local_closure(directory: Path, inventory: Path, roots: list[str]) -> dict[str, PackageRecord]:
local = load_local_packages(directory)
selected, unresolved = resolve_closure(roots, load_installed_inventory(inventory), local, {})
missing = sorted(set(roots) - set(selected))
if missing or unresolved:
detail = "; ".join([*(f"missing root: {item}" for item in missing), *unresolved])
raise ValueError(f"offline Debian dependency closure is incomplete: {detail}")
return selected
def _sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with Path(path).open("rb") as handle:
for chunk in iter(lambda: handle.read(4 * 1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def download_records(records: dict[str, PackageRecord], output: Path) -> list[Path]:
output = Path(output)
output.mkdir(parents=True, exist_ok=True)
downloaded: list[Path] = []
for record in sorted(records.values(), key=lambda item: item.name):
if record.local_path is not None:
continue
if not record.filename or not record.sha256 or not record.size:
raise ValueError(f"repository metadata is incomplete for {record.name}")
target = output / Path(record.filename).name
if target.exists():
if target.stat().st_size != record.size or _sha256_file(target) != record.sha256:
raise ValueError(f"existing download does not match repository metadata: {target}")
downloaded.append(target)
continue
url = urljoin(record.repository_base.rstrip("/") + "/", record.filename)
partial = target.with_suffix(target.suffix + ".partial")
with urlopen(url, timeout=60) as response, partial.open("wb") as handle:
while chunk := response.read(1024 * 1024):
handle.write(chunk)
if partial.stat().st_size != record.size or _sha256_file(partial) != record.sha256:
partial.unlink(missing_ok=True)
raise ValueError(f"download verification failed: {record.name}")
partial.replace(target)
downloaded.append(target)
return downloaded
def main() -> int:
parser = argparse.ArgumentParser(description="Resolve a minimal Debian 12/AArch64 offline closure")
parser.add_argument("--inventory", type=Path, required=True)
parser.add_argument("--local", type=Path, required=True)
parser.add_argument("--index", action="append", default=[], metavar="PATH=REPOSITORY_BASE")
parser.add_argument("--root", action="append", required=True)
parser.add_argument("--download-to", type=Path)
parser.add_argument("--report", type=Path)
args = parser.parse_args()
available: dict[str, PackageRecord] = {}
index_info: list[dict[str, str]] = []
for value in args.index:
path_text, separator, repository_base = value.partition("=")
if not separator:
parser.error("--index must be PATH=REPOSITORY_BASE")
path = Path(path_text).resolve()
for name, record in load_package_index(path, repository_base).items():
available.setdefault(name, record)
index_info.append({"file": path.name, "sha256": _sha256_file(path), "base": repository_base})
local = load_local_packages(args.local)
selected, unresolved = resolve_closure(
args.root, load_installed_inventory(args.inventory), local, available
)
if unresolved:
raise SystemExit("\n".join(unresolved))
downloaded = download_records(selected, args.download_to) if args.download_to else []
report = {
"schema_version": 1,
"architecture": "arm64",
"roots": args.root,
"inventory_sha256": _sha256_file(args.inventory),
"indices": index_info,
"selected": [
{
"package": item.name,
"version": item.version,
"source": "local" if item.local_path else item.filename,
"sha256": _sha256_file(item.local_path) if item.local_path else item.sha256,
"size": item.local_path.stat().st_size if item.local_path else item.size,
}
for item in sorted(selected.values(), key=lambda record: record.name)
],
"downloaded_files": [item.name for item in downloaded],
}
rendered = json.dumps(report, ensure_ascii=False, indent=2) + "\n"
if args.report:
args.report.write_text(rendered, encoding="utf-8")
else:
print(rendered, end="")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,180 @@
from __future__ import annotations
import argparse
import json
import os
import subprocess
import sys
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Callable, Sequence
EXPECTED_PINCTRL = "allwinner,sun50i-h616-pinctrl"
DISABLED_SERVICES = (
"lightdm.service",
"bluetooth.service",
"aw859-bluetooth.service",
"gpioc-server.service",
"map_device.service",
)
@dataclass(frozen=True)
class CommandResult:
returncode: int
stdout: str = ""
stderr: str = ""
@dataclass(frozen=True)
class HostPaths:
pinctrl_compatible: Path
dev_mem: Path
i2c_device: Path
cpu3: Path
native_library: Path
@classmethod
def detect(
cls,
root: Path = Path("/"),
source_root: Path | None = None,
) -> "HostPaths":
source_root = source_root or Path(__file__).resolve().parents[1]
compatible = (
root
/ "proc"
/ "device-tree"
/ "soc"
/ "pinctrl@300b000"
/ "compatible"
)
return cls(
pinctrl_compatible=compatible,
dev_mem=root / "dev" / "mem",
i2c_device=root / "dev" / "i2c-1",
cpu3=root / "sys" / "devices" / "system" / "cpu" / "cpu3",
native_library=(
source_root / "app" / "display" / "native" / "libh618_hub75.so"
),
)
@dataclass(frozen=True)
class HostCheck:
name: str
ok: bool
detail: str
Runner = Callable[[Sequence[str]], CommandResult]
def run_command(args: Sequence[str]) -> CommandResult:
completed = subprocess.run(
list(args),
check=False,
capture_output=True,
text=True,
)
return CommandResult(completed.returncode, completed.stdout, completed.stderr)
def _compatible_values(path: Path) -> tuple[str, ...]:
if not path.is_file():
return ()
return tuple(
value.decode("ascii", errors="replace")
for value in path.read_bytes().split(b"\0")
if value
)
def check_profile(
paths: HostPaths,
*,
service_only: bool = False,
runner: Runner = run_command,
) -> list[HostCheck]:
compatible = _compatible_values(paths.pinctrl_compatible)
architecture = runner(("uname", "-m"))
network_manager = runner(("systemctl", "is-active", "NetworkManager.service"))
checks = [
HostCheck(
"H618 PI pinctrl",
EXPECTED_PINCTRL in compatible,
", ".join(compatible) if compatible else "compatible node missing",
),
HostCheck(
"/dev/mem accessible",
paths.dev_mem.exists()
and os.access(paths.dev_mem, os.R_OK | os.W_OK),
str(paths.dev_mem),
),
HostCheck(
"/dev/i2c-1 present",
paths.i2c_device.exists(),
str(paths.i2c_device),
),
HostCheck("CPU3 present", paths.cpu3.is_dir(), str(paths.cpu3)),
HostCheck(
"native HUB75 library",
paths.native_library.is_file(),
str(paths.native_library),
),
HostCheck(
"AArch64 userspace",
architecture.returncode == 0 and architecture.stdout.strip() == "aarch64",
architecture.stdout.strip() or architecture.stderr.strip(),
),
HostCheck(
"NetworkManager active",
network_manager.returncode == 0
and network_manager.stdout.strip() == "active",
network_manager.stdout.strip() or network_manager.stderr.strip(),
),
]
if not service_only:
route = runner(("ip", "route", "show", "default"))
checks.insert(
-1,
HostCheck(
"default network route",
route.returncode == 0 and bool(route.stdout.strip()),
route.stdout.strip() or route.stderr.strip(),
),
)
for unit in DISABLED_SERVICES:
state = runner(("systemctl", "is-active", unit))
checks.append(
HostCheck(
f"{unit} inactive",
state.stdout.strip() != "active",
state.stdout.strip() or state.stderr.strip() or "inactive",
)
)
return checks
def main(argv: Sequence[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description="Verify the dedicated WalnutPi ZeroW/H618 host profile."
)
parser.add_argument("command", choices=("check",))
parser.add_argument("--service", action="store_true")
parser.add_argument("--json", action="store_true")
args = parser.parse_args(argv)
checks = check_profile(HostPaths.detect(), service_only=args.service)
if args.json:
print(json.dumps([asdict(check) for check in checks], ensure_ascii=False))
else:
for check in checks:
print(f"{'PASS' if check.ok else 'FAIL'}: {check.name}: {check.detail}")
return 0 if all(check.ok for check in checks) else 1
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,136 @@
#!/bin/sh
set -eu
if [ "$(id -u)" -ne 0 ]; then
echo "deploy_walnutpi.sh must run as root" >&2
exit 1
fi
SOURCE_ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
PROJECT_ROOT=$(CDPATH= cd -- "$SOURCE_ROOT/.." && pwd)
WHEELHOUSE=${WHEELHOUSE:-$PROJECT_ROOT/发布更新相关/其他依赖/aarch64-py311}
FRPC_BUNDLE=${FRPC_BUNDLE:-$PROJECT_ROOT/发布更新相关/其他依赖/frp/0.71.0/linux-arm64}
DEFER_SERVICE_START=${DEFER_SERVICE_START:-0}
MANIFEST=$WHEELHOUSE/SHA256SUMS
TARGET=/opt/matrix-screen-controller
UNIT=/etc/systemd/system/matrix-screen-controller.service
OTA_UNIT=/etc/systemd/system/matrix-screen-controller-ota.service
CONVERTER_UNIT=/etc/systemd/system/matrix-screen-converter@.service
if [ "$(uname -m)" != "aarch64" ]; then
echo "this payload is only for the WalnutPi AArch64 host" >&2
exit 1
fi
for command in ffmpeg ffprobe heif-convert flock; do
if ! command -v "$command" >/dev/null 2>&1; then
echo "required media decoder command is missing: $command" >&2
echo "install Debian packages ffmpeg and libheif-examples before deployment" >&2
exit 1
fi
done
FILTERS=$(ffmpeg -hide_banner -filters 2>/dev/null)
for filter in zscale tonemap; do
if ! printf '%s\n' "$FILTERS" | grep -Eq "[[:space:]]$filter[[:space:]]"; then
echo "required FFmpeg filter is missing: $filter" >&2
exit 1
fi
done
if [ ! -f "$MANIFEST" ]; then
echo "offline wheel manifest missing: $MANIFEST" >&2
exit 1
fi
if [ -e "$TARGET" ]; then
if [ "$DEFER_SERVICE_START" != "1" ]; then
echo "$TARGET already exists; this fresh-install script will not overwrite it" >&2
exit 1
fi
if [ ! -f "$TARGET/VERSION" ] || [ "$(cat "$TARGET/VERSION")" != "$(cat "$SOURCE_ROOT/VERSION")" ]; then
echo "$TARGET exists but is not the same software version; refusing first-boot recovery" >&2
exit 1
fi
for path in \
"$TARGET/.venv/bin/python" \
"$TARGET/scripts/dedicated_host.py" \
"$TARGET/app/display/native/libh618_hub75.so" \
"$TARGET/app/display/native/hub75_safeoff" \
"$TARGET/systemd/matrix-screen-controller.service" \
"$TARGET/systemd/matrix-screen-controller-ota.service" \
"$TARGET/systemd/matrix-screen-converter@.service"; do
if [ ! -e "$path" ]; then
echo "incomplete first-boot deployment cannot be recovered: $path" >&2
exit 1
fi
done
FRPC_BUNDLE="$FRPC_BUNDLE" DEFER_SERVICE_START=1 /bin/sh "$TARGET/scripts/install_frpc_system.sh"
"$TARGET/.venv/bin/python" "$TARGET/scripts/dedicated_host.py" check --service
install -m 0644 "$TARGET/systemd/matrix-screen-controller.service" "$UNIT"
install -m 0644 "$TARGET/systemd/matrix-screen-controller-ota.service" "$OTA_UNIT"
install -m 0644 "$TARGET/systemd/matrix-screen-converter@.service" "$CONVERTER_UNIT"
systemctl daemon-reload
systemctl enable matrix-screen-controller.service
systemctl is-enabled --quiet matrix-screen-controller.service
echo "Recovered the verified same-version deployment; service start is deferred until reboot"
exit 0
fi
(cd "$WHEELHOUSE" && sha256sum -c SHA256SUMS)
STAGING=$(mktemp -d /opt/matrix-screen-controller.stage.XXXXXX)
cleanup() {
case ${STAGING:-} in
/opt/matrix-screen-controller.stage.*)
if [ -d "$STAGING" ]; then
rm -rf -- "$STAGING"
fi
;;
esac
}
trap cleanup EXIT HUP INT TERM
cp -a "$SOURCE_ROOT/." "$STAGING/"
python3 -m venv "$STAGING/.venv"
"$STAGING/.venv/bin/pip" install \
--no-index \
--find-links "$WHEELHOUSE" \
-r "$STAGING/requirements.txt"
make -C "$STAGING/app/display/native" clean all test
systemctl set-default multi-user.target
systemctl disable --now \
lightdm.service \
bluetooth.service \
aw859-bluetooth.service \
gpioc-server.service \
map_device.service
if [ "$DEFER_SERVICE_START" = "1" ]; then
"$STAGING/.venv/bin/python" "$STAGING/scripts/dedicated_host.py" check --service
else
"$STAGING/.venv/bin/python" "$STAGING/scripts/dedicated_host.py" check
fi
install -d -m 0711 /var/lib/matrix-screen-controller
install -d -m 0750 /run/matrix-screen-controller
mv -- "$STAGING" "$TARGET"
STAGING=
FRPC_BUNDLE="$FRPC_BUNDLE" DEFER_SERVICE_START="$DEFER_SERVICE_START" /bin/sh "$TARGET/scripts/install_frpc_system.sh"
install -m 0644 "$TARGET/systemd/matrix-screen-controller.service" "$UNIT"
install -m 0644 "$TARGET/systemd/matrix-screen-controller-ota.service" "$OTA_UNIT"
install -m 0644 "$TARGET/systemd/matrix-screen-converter@.service" "$CONVERTER_UNIT"
systemctl daemon-reload
if [ "$DEFER_SERVICE_START" = "1" ]; then
systemctl enable matrix-screen-controller.service
systemctl is-enabled --quiet matrix-screen-controller.service
echo "WalnutPi Matrix Screen Controller installed; service start is deferred until reboot"
exit 0
fi
systemctl enable --now matrix-screen-controller.service
attempt=0
while [ "$attempt" -lt 30 ]; do
if curl --fail --silent http://127.0.0.1:8080/api/status >/dev/null; then
break
fi
attempt=$((attempt + 1))
sleep 1
done
systemctl --no-pager --full status matrix-screen-controller.service
curl --fail --silent --show-error http://127.0.0.1:8080/api/status >/dev/null
echo "WalnutPi Matrix Screen Controller deployed successfully"
@@ -0,0 +1,468 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
from datetime import datetime, timezone
import hashlib
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import uuid
SOURCE_ROOT = Path(__file__).resolve().parents[1]
if str(SOURCE_ROOT) not in sys.path:
sys.path.insert(0, str(SOURCE_ROOT))
from app.ota.package import build_package, extract_payload
from app.ota.versioning import SoftwareVersion, read_software_version
from app.ota.policy import export_bundle, package_format, release_metadata, read_policy
from scripts.build_sd_image import build_image, sha256_file
from scripts.fat16_image import Fat16Image
from scripts.image_config import create_config_file, read_config_file
def next_patch(version: SoftwareVersion) -> SoftwareVersion:
return SoftwareVersion(version.major, version.minor, version.patch + 1)
def _image_readme(version: SoftwareVersion, manifest: dict, notes: str, config: dict) -> str:
return (
f"# 奇妙小屏幕控制器 IMAGE {version}\n\n"
f"- 软件版本:`{version}`\n"
f"- 导出时间:`{manifest['created_at']}`\n"
f"- 说明:{notes.strip()}\n"
f"- 产物:`{manifest['artifact']}`\n"
f"- SHA-256:`{manifest['image_sha256']}`\n\n"
"## 未修改镜像的默认设置\n\n"
f"- Linux 用户名:`{config['account']['username']}`\n"
f"- Linux 密码:`{config['account']['password']}`\n"
f"- Wi-Fi SSID:`{config['wifi']['ssid']}`\n"
f"- Wi-Fi 密码:`{config['wifi']['password']}`\n"
"- IPv4:`DHCP`\n\n"
"> 这些是公开的默认凭据。建议刷写前使用镜像编辑器修改;直接使用未修改镜像时,"
"请按上述 SSID 和密码开启热点,并在首次登录后立即更换账户及 Wi-Fi 设置。\n\n"
"首次启动会离线安装全部软件并自动重启一次。请等待设备重新连接热点后再通过 SSH 或网页访问。\n\n"
"本目录是可删除的发布产物,不得被源码、脚本或后续版本引用。\n"
)
def _ota_readme(version: SoftwareVersion, manifest: dict, notes: str) -> str:
metadata = release_metadata(version)
return (
f"# 奇妙小屏幕控制器 OTA {version}\n\n"
f"- 软件版本:`{version}`\n"
f"- 导出时间:`{manifest['created_at']}`\n"
f"- 说明:{notes.strip()}\n"
f"- 产物:`{manifest['artifact']}`\n\n"
f"- 包类型:{'软件安装包(必经升级版本)' if metadata['is_checkpoint'] else '应用更新包'}\n"
f"- 前置系列基线:`{metadata['required_checkpoint']}`\n\n"
"在系统设置上传 OTA;每个 minor 的 .0 必须按顺序安装,同系列补丁允许跳过。"
"1.1.0 含 frpc 0.71.0 离线组件;已有完整组件会跳过,否则修复并保留配置和启停状态。"
"初次安装默认关闭。禁止跳过失败测试;失败自动恢复。\n\n"
"本目录是可删除的发布产物,不得被源码、脚本或后续版本引用。\n"
)
def _copy_source(source: Path, destination: Path, version: SoftwareVersion) -> None:
ignored = shutil.ignore_patterns(".venv", "__pycache__", ".pytest_cache", "node_modules", "data", "output", ".playwright-cli", "system-dependencies")
shutil.copytree(source, destination, ignore=ignored)
(destination / "VERSION").write_text(f"{version}\n", encoding="utf-8", newline="\n")
def _history(path: Path) -> dict:
if path.is_file():
document = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(document, dict) or document.get("schema_version") != 1 or not isinstance(document.get("releases"), list):
raise ValueError("发布记录.json is invalid")
return document
return {
"schema_version": 1,
"releases": [
{
"version": "1.0.1",
"artifact_type": "ota",
"created_at": "2026-08-13T15:31:23+08:00",
"notes": "新增浏览器全量 OTA。",
"artifact_path": "发布更新相关/OTA数据包/1.0.1/matrix-screen-controller-1.0.1.ota",
"artifact_sha256": sha256_file(path.parent / "发布更新相关" / "OTA数据包" / "1.0.1" / "matrix-screen-controller-1.0.1.ota"),
}
],
}
def _atomic_json(path: Path, document: dict) -> None:
temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
temporary.write_text(json.dumps(document, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", newline="\n")
os.replace(temporary, path)
def _atomic_bytes(path: Path, body: bytes) -> None:
temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
temporary.write_bytes(body)
os.replace(temporary, path)
def _require_image_host() -> None:
if os.name != "posix" or not hasattr(os, "geteuid") or os.geteuid() != 0:
raise ValueError("image export must run as root on Linux")
missing = [name for name in ("bash", "losetup", "mount", "umount", "systemctl", "sha256sum") if shutil.which(name) is None]
if missing:
raise ValueError(f"image export host is missing required commands: {', '.join(missing)}")
def _validated_repair_input(
final_directory: Path,
history: dict,
version: SoftwareVersion,
) -> tuple[bytes, int]:
manifest_path = final_directory / "manifest.json"
if not manifest_path.is_file():
raise ValueError("current image release manifest is missing")
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
artifact_name = f"matrix-screen-controller-{version}.img"
artifact = final_directory / artifact_name
sidecar = final_directory / f"{artifact_name}.sha256"
if (
manifest.get("artifact_type") != "image"
or manifest.get("software_version") != str(version)
or manifest.get("artifact") != artifact_name
or not artifact.is_file()
or not sidecar.is_file()
):
raise ValueError("current image release files do not match the repair target")
digest = sha256_file(artifact)
expected_sidecar = f"{digest} {artifact_name}\n".encode("ascii")
if sidecar.read_bytes() != expected_sidecar or manifest.get("image_sha256") != digest:
raise ValueError("current image release digest is inconsistent")
matches = [
(index, item)
for index, item in enumerate(history["releases"])
if item.get("version") == str(version)
]
if len(matches) != 1:
raise ValueError("current version must have exactly one release record before repair")
index, record = matches[0]
expected_path = f"发布更新相关/导出包/{version}/{artifact_name}"
if (
record.get("artifact_type") != "image"
or record.get("artifact_path") != expected_path
or record.get("artifact_sha256") != digest
):
raise ValueError("current image release record is inconsistent")
with Fat16Image(artifact) as image:
config_bytes = image.read_file("MSCCFG.BIN")
config, _, _ = read_config_file(config_bytes)
if config["software_version"] != str(version):
raise ValueError("current image configuration version does not match VERSION")
return config_bytes, index
def _commit_repair(
final_directory: Path,
temporary_directory: Path,
history_path: Path,
history: dict,
) -> Path | None:
backup = final_directory.parent / f".{final_directory.name}.{uuid.uuid4().hex}.invalid-backup"
os.replace(final_directory, backup)
try:
os.replace(temporary_directory, final_directory)
_atomic_json(history_path, history)
except BaseException:
if final_directory.exists():
shutil.rmtree(final_directory, ignore_errors=True)
os.replace(backup, final_directory)
raise
shutil.rmtree(backup, ignore_errors=True)
return backup if backup.exists() else None
def main() -> int:
parser = argparse.ArgumentParser(description="Export one versioned OTA or SD image release")
parser.add_argument("kind", choices=("ota", "image"))
parser.add_argument("--notes", required=True)
parser.add_argument("--version", type=SoftwareVersion.parse, help="explicit release version, e.g. 1.1.0")
parser.add_argument("--config", type=Path, help="required JSON provisioning config for image export")
parser.add_argument("--candidate-output", type=Path, help="build a verified image candidate directory without publishing")
parser.add_argument("--validated-candidate", type=Path, help="exact real-device validated OTA file or image candidate directory")
parser.add_argument("--validation-report", type=Path, help="matching real-device validation JSON")
parser.add_argument(
"--repair-current",
action="store_true",
help="rebuild and atomically replace the registered current-version image",
)
args = parser.parse_args()
if args.repair_current and args.version is not None:
parser.error("--repair-current cannot be combined with --version")
if args.kind == "ota" and args.repair_current:
if args.validated_candidate is None or args.validation_report is None:
parser.error("OTA repair requires --validated-candidate and --validation-report")
from scripts.repair_ota_release import promote
print(promote(SOURCE_ROOT, args.validated_candidate, args.validation_report, args.notes))
return 0
image_promotion = args.kind == "image" and args.validated_candidate is not None
if image_promotion:
if args.validation_report is None:
parser.error("image candidate promotion requires --validation-report")
if args.config is not None or args.version is not None or args.candidate_output is not None or args.repair_current:
parser.error("image candidate promotion cannot use --config, --version, --candidate-output, or --repair-current")
try:
_require_image_host()
except ValueError as exc:
parser.error(str(exc))
from scripts.promote_image_release import promote
print(promote(SOURCE_ROOT, args.validated_candidate, args.validation_report, args.notes))
return 0
if args.validated_candidate is not None or args.validation_report is not None:
parser.error("candidate validation options require OTA --repair-current or image promotion")
if args.kind == "image" and args.config is None and not args.repair_current:
parser.error("--config is required for image candidate export")
if args.kind == "image" and not args.repair_current and args.candidate_output is None:
parser.error("new image releases must use --candidate-output and pass real-card validation before publishing")
if args.kind == "ota" and args.candidate_output is not None:
parser.error("--candidate-output is only supported for image export")
if args.repair_current and args.config is not None:
parser.error("--repair-current reuses the registered image configuration; do not pass --config")
if args.kind == "image":
try:
_require_image_host()
except ValueError as exc:
parser.error(str(exc))
source = SOURCE_ROOT
project = source.parent
lock = project / ".release-export.lock"
try:
descriptor = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
except FileExistsError:
parser.error("another release export is already active")
os.close(descriptor)
try:
current = read_software_version(source)
target = current if args.repair_current else (args.version or next_patch(current))
dependency_bundle = None
if not args.repair_current and args.kind == "ota":
dependency_bundle = export_bundle(source, project / "发布更新相关" / "其他依赖", current, target)
if args.kind == "image" and not args.repair_current and target <= current:
parser.error("image candidate version must be newer than the current version")
output_root = project / "发布更新相关" / "OTA数据包" if args.kind == "ota" else project / "发布更新相关" / "导出包"
official_directory = output_root / str(target)
final_directory = args.candidate_output.resolve() if args.candidate_output is not None else official_directory
if args.candidate_output is not None:
try:
final_directory.relative_to(output_root.resolve())
except ValueError:
pass
else:
parser.error("candidate output must be outside the formal image release directory")
if final_directory.exists() and not args.repair_current:
parser.error(f"release directory already exists: {final_directory}")
if official_directory.exists() and args.candidate_output is not None:
parser.error(f"formal image release directory already exists: {official_directory}")
if args.repair_current and not final_directory.is_dir():
parser.error(f"current image release directory is missing: {final_directory}")
output_root.mkdir(parents=True, exist_ok=True)
history_path = project / "发布记录.json"
history = _history(history_path)
policy = read_policy(source)
for previous in history["releases"]:
recorded = previous.get("component_checkpoints", [])
if any(entry not in policy["checkpoints"] for entry in recorded):
parser.error("已发布的依赖节点不可改写;请追加新的 minor .0 节点")
history_original = history_path.read_bytes() if history_path.is_file() else None
version_original = (source / "VERSION").read_bytes()
repair_record_index: int | None = None
repair_config: bytes | None = None
if args.repair_current:
try:
repair_config, repair_record_index = _validated_repair_input(
final_directory, history, target
)
except (OSError, ValueError, json.JSONDecodeError) as exc:
parser.error(str(exc))
build_parent = final_directory.parent if args.candidate_output is not None else output_root
build_parent.mkdir(parents=True, exist_ok=True)
temporary_directory = build_parent / f".{final_directory.name}.{uuid.uuid4().hex}.building"
temporary_directory.mkdir()
published = False
replaced_directory: Path | None = None
try:
with tempfile.TemporaryDirectory(prefix="matrix-release-source-") as staging_text:
staged_source = Path(staging_text) / "核桃派软件源代码"
_copy_source(source, staged_source, target)
created = datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
if args.kind == "ota":
artifact = temporary_directory / f"matrix-screen-controller-{target}.ota"
info = build_package(
staged_source,
project / "发布更新相关" / "其他依赖" / "aarch64-py311",
artifact,
version=target,
release_notes=args.notes,
system_dependencies=dependency_bundle,
)
verified = Path(staging_text) / "verified-ota"
extract_payload(info, verified)
if (verified / "software/VERSION").read_text(encoding="utf-8").strip() != str(target):
raise ValueError("exported payload version mismatch")
if (verified / "software/FEATURE_UPDATED_AT").read_bytes() != (source / "FEATURE_UPDATED_AT").read_bytes():
raise ValueError("exported feature timestamp mismatch")
if read_policy(verified / "software") != policy:
raise ValueError("exported dependency policy mismatch")
manifest = {
"format_version": package_format(target),
"artifact_type": "ota",
"software_version": str(target),
"created_at": info.created_at,
"notes": args.notes.strip(),
"artifact": artifact.name,
"artifact_bytes": artifact.stat().st_size,
"artifact_sha256": sha256_file(artifact),
**release_metadata(target),
}
else:
if repair_config is not None:
config: dict | bytes = repair_config
else:
config = json.loads(args.config.read_text(encoding="utf-8"))
config["software_version"] = str(target)
artifact = temporary_directory / f"matrix-screen-controller-{target}.img"
bootstrap_bundle = Path(staging_text) / "MSCBOOT.TGZ"
kernel_dependency = (
project
/ "发布更新相关"
/ "其他依赖"
/ "aarch64-kernel"
/ "6.1.31-matrix-axp313a1"
)
kernel_source = (
project
/ "发布更新相关"
/ "其他依赖"
/ "kernel-source"
/ "walnutpi-linux-6.1.31-30ff3fd5"
)
manifest = build_image(
project / "发布更新相关" / "核桃派镜像" / "2025-3-6_V2.5.1_WalnutPi-1B_6.1.31_debian12_server.img",
staged_source,
project / "发布更新相关" / "其他依赖" / "aarch64-py311",
project / "发布更新相关" / "其他依赖" / "debian12-aarch64",
kernel_dependency,
kernel_source,
config,
artifact,
bootstrap_bundle,
project / "发布更新相关" / "其他依赖" / "frp" / "0.71.0" / "linux-arm64",
)
subprocess.run(
[
"bash",
str(staged_source / "scripts" / "install_image_bootstrap.sh"),
str(artifact),
str(staged_source / "systemd" / "matrix-image-firstboot.service"),
str(staged_source / "systemd" / "10-walnutpi-screen-hardening.conf"),
str(kernel_dependency),
str(bootstrap_bundle),
],
check=True,
)
subprocess.run(
[
"bash",
str(staged_source / "scripts" / "verify_image_bootstrap.sh"),
str(artifact),
str(staged_source / "systemd" / "matrix-image-firstboot.service"),
str(staged_source / "systemd" / "10-walnutpi-screen-hardening.conf"),
str(kernel_dependency),
str(bootstrap_bundle),
],
check=True,
)
manifest["created_at"] = datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
manifest["image_bytes"] = artifact.stat().st_size
manifest["image_sha256"] = sha256_file(artifact)
manifest.update({"artifact_type": "image", "notes": args.notes.strip(), "artifact": artifact.name})
config_document, _generation, _slot = read_config_file(
repair_config if repair_config is not None else create_config_file(config)
)
manifest["config_sha256"] = hashlib.sha256(
repair_config if repair_config is not None else create_config_file(config_document)
).hexdigest()
(temporary_directory / f"{artifact.name}.sha256").write_text(
f"{manifest['image_sha256'] if args.kind == 'image' else manifest['artifact_sha256']} {artifact.name}\n",
encoding="ascii",
newline="\n",
)
(temporary_directory / "manifest.json").write_text(
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
newline="\n",
)
(temporary_directory / "README.md").write_text(
_image_readme(target, manifest, args.notes, config_document)
if args.kind == "image"
else _ota_readme(target, manifest, args.notes),
encoding="utf-8",
newline="\n",
)
if args.candidate_output is not None:
os.replace(temporary_directory, final_directory)
published = True
print(final_directory)
return 0
release_record = {
"version": str(target),
"artifact_type": args.kind,
"created_at": manifest["created_at"],
"notes": args.notes.strip(),
"artifact_path": f"{'发布更新相关/OTA数据包' if args.kind == 'ota' else '发布更新相关/导出包'}/{target}/{artifact.name}",
"artifact_sha256": manifest["image_sha256"] if args.kind == "image" else manifest["artifact_sha256"],
**(release_metadata(target) if args.kind == "ota" else {}),
**({"component_checkpoints": policy["checkpoints"]} if args.kind == "ota" else {}),
}
if args.repair_current:
assert repair_record_index is not None
history["releases"][repair_record_index] = release_record
replaced_directory = _commit_repair(
final_directory,
temporary_directory,
history_path,
history,
)
published = True
if replaced_directory is not None and replaced_directory.exists():
print(f"warning: obsolete image backup still requires cleanup: {replaced_directory}", file=sys.stderr)
replaced_directory = None
else:
os.replace(temporary_directory, final_directory)
published = True
history["releases"].append(release_record)
_atomic_json(history_path, history)
version_temp = source / ".VERSION.release.tmp"
version_temp.write_text(f"{target}\n", encoding="utf-8", newline="\n")
os.replace(version_temp, source / "VERSION")
print(final_directory)
except BaseException:
shutil.rmtree(temporary_directory, ignore_errors=True)
if published and not args.repair_current and args.candidate_output is None:
shutil.rmtree(final_directory, ignore_errors=True)
if history_original is None:
history_path.unlink(missing_ok=True)
else:
_atomic_bytes(history_path, history_original)
_atomic_bytes(source / "VERSION", version_original)
(source / ".VERSION.release.tmp").unlink(missing_ok=True)
if replaced_directory is not None and replaced_directory.exists() and not final_directory.exists():
os.replace(replaced_directory, final_directory)
raise
finally:
lock.unlink(missing_ok=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,213 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
import struct
from typing import BinaryIO
class Fat16Error(ValueError):
pass
@dataclass(frozen=True)
class FatEntry:
directory_offset: int
first_cluster: int
size: int
class Fat16Image:
def __init__(self, path: Path, *, writable: bool = False):
self.path = Path(path)
self.handle: BinaryIO = self.path.open("r+b" if writable else "rb")
self.writable = writable
self._load_geometry()
def __enter__(self) -> "Fat16Image":
return self
def __exit__(self, *_args: object) -> None:
self.handle.close()
def _load_geometry(self) -> None:
self.handle.seek(0)
mbr = self.handle.read(512)
if len(mbr) != 512 or mbr[510:512] != b"\x55\xaa":
raise Fat16Error("image does not contain a valid MBR")
entry = mbr[446:462]
self.partition_lba = struct.unpack_from("<I", entry, 8)[0]
self.partition_sectors = struct.unpack_from("<I", entry, 12)[0]
self.partition_offset = self.partition_lba * 512
if entry[4] not in {0x04, 0x06, 0x0E, 0x0C} or not self.partition_lba:
raise Fat16Error("first image partition is not a supported FAT partition")
self.handle.seek(self.partition_offset)
bpb = self.handle.read(512)
self.bytes_per_sector = struct.unpack_from("<H", bpb, 11)[0]
self.sectors_per_cluster = bpb[13]
self.reserved_sectors = struct.unpack_from("<H", bpb, 14)[0]
self.fat_count = bpb[16]
self.root_entries = struct.unpack_from("<H", bpb, 17)[0]
self.fat_sectors = struct.unpack_from("<H", bpb, 22)[0]
if self.bytes_per_sector != 512 or not self.fat_sectors or not self.root_entries:
raise Fat16Error("first image partition is not FAT16")
self.cluster_bytes = self.bytes_per_sector * self.sectors_per_cluster
self.fat_offset = self.partition_offset + self.reserved_sectors * self.bytes_per_sector
self.root_offset = self.fat_offset + self.fat_count * self.fat_sectors * self.bytes_per_sector
self.root_bytes = self.root_entries * 32
self.data_offset = self.root_offset + self.root_bytes
data_sectors = self.partition_sectors - (
self.reserved_sectors + self.fat_count * self.fat_sectors + self.root_bytes // self.bytes_per_sector
)
self.cluster_count = data_sectors // self.sectors_per_cluster
if not 4085 <= self.cluster_count < 65525:
raise Fat16Error("FAT partition cluster count is not FAT16")
@staticmethod
def _name83(name: str) -> bytes:
value = name.upper()
if value.count(".") > 1:
raise Fat16Error("FAT filename must use 8.3 form")
base, dot, extension = value.partition(".")
allowed = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_$~!#%&-{}()@'`"
if not 1 <= len(base) <= 8 or len(extension) > 3 or any(c not in allowed for c in base + extension):
raise Fat16Error(f"unsupported FAT 8.3 filename: {name}")
return base.ljust(8).encode("ascii") + extension.ljust(3).encode("ascii")
def _read_fat(self) -> bytearray:
self.handle.seek(self.fat_offset)
return bytearray(self.handle.read(self.fat_sectors * self.bytes_per_sector))
def _fat_value(self, fat: bytes, cluster: int) -> int:
return struct.unpack_from("<H", fat, cluster * 2)[0]
def _set_fat_value(self, fat: bytearray, cluster: int, value: int) -> None:
struct.pack_into("<H", fat, cluster * 2, value)
def _write_fats(self, fat: bytes) -> None:
if not self.writable:
raise Fat16Error("image was opened read-only")
for index in range(self.fat_count):
self.handle.seek(self.fat_offset + index * self.fat_sectors * self.bytes_per_sector)
self.handle.write(fat)
def find(self, name: str) -> FatEntry | None:
expected = self._name83(name)
self.handle.seek(self.root_offset)
root = self.handle.read(self.root_bytes)
for index in range(self.root_entries):
offset = index * 32
raw = root[offset : offset + 32]
if raw[0] == 0x00:
break
if raw[0] == 0xE5 or raw[11] == 0x0F:
continue
if raw[:11] == expected:
return FatEntry(
self.root_offset + offset,
struct.unpack_from("<H", raw, 26)[0],
struct.unpack_from("<I", raw, 28)[0],
)
return None
def _chain(self, first_cluster: int, fat: bytes) -> list[int]:
if first_cluster < 2:
return []
result: list[int] = []
seen: set[int] = set()
cluster = first_cluster
while 2 <= cluster < 0xFFF8:
if cluster in seen or cluster > self.cluster_count + 1:
raise Fat16Error("FAT cluster chain is invalid")
seen.add(cluster)
result.append(cluster)
cluster = self._fat_value(fat, cluster)
return result
def _cluster_offset(self, cluster: int) -> int:
return self.data_offset + (cluster - 2) * self.cluster_bytes
def read_file(self, name: str) -> bytes:
entry = self.find(name)
if entry is None:
raise FileNotFoundError(name)
fat = self._read_fat()
remaining = entry.size
result = bytearray()
for cluster in self._chain(entry.first_cluster, fat):
self.handle.seek(self._cluster_offset(cluster))
chunk = self.handle.read(min(self.cluster_bytes, remaining))
result.extend(chunk)
remaining -= len(chunk)
if remaining == 0:
break
if remaining:
raise Fat16Error(f"FAT file is truncated: {name}")
return bytes(result)
def free_bytes(self) -> int:
fat = self._read_fat()
free_clusters = sum(
1
for cluster in range(2, self.cluster_count + 2)
if self._fat_value(fat, cluster) == 0
)
return free_clusters * self.cluster_bytes
def _free_chain(self, first_cluster: int, fat: bytearray) -> None:
for cluster in self._chain(first_cluster, fat):
self._set_fat_value(fat, cluster, 0)
def _directory_slot(self, name: str) -> int:
existing = self.find(name)
if existing is not None:
return existing.directory_offset
self.handle.seek(self.root_offset)
root = self.handle.read(self.root_bytes)
for index in range(self.root_entries):
if root[index * 32] in {0x00, 0xE5}:
return self.root_offset + index * 32
raise Fat16Error("FAT16 root directory is full")
def write_file(self, name: str, data: bytes, *, contiguous: bool = False) -> None:
if not self.writable:
raise Fat16Error("image was opened read-only")
content = bytes(data)
fat = self._read_fat()
existing = self.find(name)
if existing is not None:
self._free_chain(existing.first_cluster, fat)
required = max(1, (len(content) + self.cluster_bytes - 1) // self.cluster_bytes)
free = [cluster for cluster in range(2, self.cluster_count + 2) if self._fat_value(fat, cluster) == 0]
if contiguous:
clusters: list[int] = []
run: list[int] = []
previous = -2
for cluster in free:
run = run + [cluster] if cluster == previous + 1 else [cluster]
previous = cluster
if len(run) == required:
clusters = run
break
else:
clusters = free[:required]
if len(clusters) != required:
raise Fat16Error("FAT16 partition does not have enough free clusters")
for index, cluster in enumerate(clusters):
next_cluster = clusters[index + 1] if index + 1 < len(clusters) else 0xFFFF
self._set_fat_value(fat, cluster, next_cluster)
chunk = content[index * self.cluster_bytes : (index + 1) * self.cluster_bytes]
self.handle.seek(self._cluster_offset(cluster))
self.handle.write(chunk)
if len(chunk) < self.cluster_bytes:
self.handle.write(bytes(self.cluster_bytes - len(chunk)))
self._write_fats(fat)
directory_offset = self._directory_slot(name)
record = bytearray(32)
record[:11] = self._name83(name)
record[11] = 0x20
struct.pack_into("<H", record, 26, clusters[0])
struct.pack_into("<I", record, 28, len(content))
self.handle.seek(directory_offset)
self.handle.write(record)
self.handle.flush()
@@ -0,0 +1,124 @@
from __future__ import annotations
import argparse
import base64
import json
from pathlib import Path
from typing import Sequence
from app.animations.store import AnimationStore
PRODUCTION_DATA_ROOT = Path("/var/lib/matrix-screen-controller")
REQUIRED_BASENAME_PREFIX = "matrix-screen-controller-preview-perf-"
class FixtureError(RuntimeError):
pass
def validate_empty_test_root(value: str | Path) -> Path:
path = Path(value).expanduser().resolve(strict=True)
if not path.is_dir():
raise FixtureError("pressure-test data root must be an existing directory")
if path == Path(path.anchor) or path == Path.home().resolve():
raise FixtureError("pressure-test data root is too broad")
if path == PRODUCTION_DATA_ROOT or PRODUCTION_DATA_ROOT in path.parents:
raise FixtureError("refusing to use the production data root")
if not path.name.startswith(REQUIRED_BASENAME_PREFIX):
raise FixtureError(
f"pressure-test directory name must start with {REQUIRED_BASENAME_PREFIX!r}"
)
if any(path.iterdir()):
raise FixtureError("pressure-test data root must be empty")
return path
def solid_scene(sequence: int) -> dict[str, object]:
if not 0 <= sequence <= 0xFFFFFF:
raise FixtureError("fixture sequence exceeds unique RGB color space")
color = bytes(((sequence >> 16) & 0xFF, (sequence >> 8) & 0xFF, sequence & 0xFF))
return {
"version": 1,
"width": 64,
"height": 64,
"pixelRgb": base64.b64encode(color * (64 * 64)).decode("ascii"),
"elements": [],
}
def fixture_name(index: int) -> str:
if index % 3 == 0:
return f"压力动图_{index:03d}_" + "很长的中文名称" * 7
if index % 3 == 1:
return f"stress_{index:03d}_" + "abcdefghijklmnopqrstuvwxyz" * 2
return f"混合Stress_{index:03d}_" + "无空格LongName" * 5
def generate_fixture(
data_root: Path,
*,
animation_count: int = 100,
frames_per_animation: int = 100,
duration_ms: int = 50,
) -> dict[str, int | str]:
if not 1 <= animation_count <= 1000:
raise FixtureError("animation count must be within 1..1000")
if not 1 <= frames_per_animation <= 30_000:
raise FixtureError("frames per animation must be within 1..30000")
if not 50 <= duration_ms <= 604_800_000:
raise FixtureError("frame duration must be within 50..604800000ms")
if animation_count * frames_per_animation > 0x1000000:
raise FixtureError("fixture needs more than 24-bit unique solid colors")
store = AnimationStore(data_root)
sequence = 0
for animation_index in range(animation_count):
def frames():
nonlocal sequence
for _frame_index in range(frames_per_animation):
scene = solid_scene(sequence)
sequence += 1
yield {"scene": scene, "name": None, "duration_ms": duration_ms}
store.create_from_frames(fixture_name(animation_index), frames())
return {
"data_root": str(data_root),
"animations": animation_count,
"frames_per_animation": frames_per_animation,
"total_frames": animation_count * frames_per_animation,
"duration_ms": duration_ms,
}
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Generate isolated 64x64 animations for thumbnail performance tests."
)
parser.add_argument("--data-root", required=True)
parser.add_argument("--animations", type=int, default=100)
parser.add_argument("--frames", type=int, default=100)
parser.add_argument("--duration-ms", type=int, default=50)
return parser
def main(argv: Sequence[str] | None = None) -> int:
args = build_parser().parse_args(argv)
try:
root = validate_empty_test_root(args.data_root)
result = generate_fixture(
root,
animation_count=args.animations,
frames_per_animation=args.frames,
duration_ms=args.duration_ms,
)
except (FixtureError, OSError, ValueError) as exc:
print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False))
return 1
print(json.dumps({"ok": True, **result}, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,132 @@
"""Hold a real-driver visual pattern until systemd or the operator stops it."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
import signal
import time
from PIL import Image
from app.display.matrix_driver import MatrixDisplayDriver
from app.display.ota_indicator import render_ota_indicator
from app.display.transforms import apply_orientation, apply_row_bit_order, get_row_bit_order
from app.display.wifi_indicator import (
WIFI_SCROLL_FPS,
WIFI_SCROLL_GAP_PIXELS,
WIFI_SCROLL_PIXELS_PER_SECOND,
render_wifi_indicator,
text_width,
)
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--brightness", type=int, default=50)
parser.add_argument("--mode", choices=("wifi", "complex", "ota"), default="wifi")
parser.add_argument(
"--message",
default="SSID: DRIVER-TEST IP: http://192.168.1.2:8080/",
)
parser.add_argument("--progress-file")
parser.add_argument("--orientation", type=int, choices=(0, 90, 180, 270), default=0)
args = parser.parse_args()
if not 1 <= args.brightness <= 100:
parser.error("brightness must be in 1..100")
stopping = False
def request_stop(_signum: int, _frame: object) -> None:
nonlocal stopping
stopping = True
signal.signal(signal.SIGINT, request_stop)
signal.signal(signal.SIGTERM, request_stop)
driver = MatrixDisplayDriver(args.brightness, limit_refresh_rate_hz=100)
cycle_pixels = (
64 + text_width(args.message) + WIFI_SCROLL_GAP_PIXELS
if args.mode == "wifi"
else 192
)
started = time.monotonic()
next_frame = started
try:
while not stopping:
now = time.monotonic()
offset = ((now - started) * WIFI_SCROLL_PIXELS_PER_SECOND) % cycle_pixels
if args.mode == "wifi":
frame = render_wifi_indicator(
args.message,
offset=offset,
connected=True,
)
elif args.mode == "complex":
frame = render_complex_pattern(offset)
else:
percent, stage = read_ota_progress(args.progress_file)
frame = render_ota_indicator(percent, f"{stage}:{int(offset) % 4}")
transformed = apply_row_bit_order(
apply_orientation(frame, args.orientation),
get_row_bit_order(),
)
driver.set_image(transformed)
next_frame += 1.0 / WIFI_SCROLL_FPS
time.sleep(max(0.0, next_frame - time.monotonic()))
finally:
driver.clear()
driver.close()
return 0
def read_ota_progress(filename: str | None) -> tuple[int, str]:
if not filename:
return 0, "准备更新"
try:
document = json.loads(Path(filename).read_text(encoding="utf-8"))
job = document.get("job") if isinstance(document, dict) else None
if not isinstance(job, dict):
return 0, "准备更新"
return max(0, min(100, int(job.get("percent", 0)))), str(job.get("message") or "更新中")[:80]
except (OSError, UnicodeError, json.JSONDecodeError, TypeError, ValueError):
return 0, "准备更新"
def render_complex_pattern(offset: float) -> Image.Image:
"""Render a dense, high-contrast 192-pixel panorama through a 64px window."""
phase = int(offset) % 192
pixels: list[tuple[int, int, int]] = []
palette = (
(255, 32, 32),
(255, 176, 24),
(48, 255, 64),
(32, 224, 255),
(48, 64, 255),
(224, 48, 255),
)
for y in range(64):
for x in range(64):
source_x = (x + phase) % 192
base = palette[(source_x // 16) % len(palette)]
checker = ((source_x // 4) ^ (y // 4)) & 1
diagonal = ((source_x + y * 2) // 8) & 1
gain = 1.0 if checker else 0.48
if diagonal:
gain *= 0.78
radial = ((source_x % 32) - 16) ** 2 + (y - 32) ** 2
if 70 <= radial <= 150:
color = (255, 255, 255)
elif radial < 36:
color = (8, 8, 8)
else:
color = tuple(max(8, min(255, int(channel * gain))) for channel in base)
pixels.append(color)
image = Image.new("RGB", (64, 64))
image.putdata(pixels)
return image
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,128 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
from dataclasses import asdict
import hashlib
import json
from pathlib import Path
import shutil
from scripts.kernel_artifact import ARTIFACT_NAME, KernelArtifactError, verify_kernel_dependency
TARGET_RELATIVE = Path("opt/matrix-image-bootstrap")
APP_RELATIVE = Path("app/MSCBOOT.TGZ")
KERNEL_RELATIVE = Path("axp313a")
DEFAULT_RESERVE_BYTES = 256 * 1024 * 1024
KERNEL_FILES = {ARTIFACT_NAME, "SHA256SUMS", "METADATA.json"}
class ImageBootstrapPayloadError(ValueError):
pass
def _root(path: Path) -> Path:
resolved = Path(path).resolve()
if not resolved.is_dir() or resolved == Path(resolved.anchor):
raise ImageBootstrapPayloadError("image root must be an explicit mounted or isolated directory")
return resolved
def _sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(4 * 1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def verify_staged_payload(root: Path, bundle: Path, dependency: Path) -> dict[str, object]:
root = _root(root)
bundle = Path(bundle).resolve()
dependency = Path(dependency).resolve()
expected_kernel = verify_kernel_dependency(dependency)
target = root / TARGET_RELATIVE
app_target = target / APP_RELATIVE
kernel_target = target / KERNEL_RELATIVE
if not target.is_dir() or {path.name for path in target.iterdir()} != {"app", "axp313a"}:
raise ImageBootstrapPayloadError("staged image bootstrap payload directories are not exact")
if not app_target.is_file() or {path.name for path in app_target.parent.iterdir()} != {"MSCBOOT.TGZ"}:
raise ImageBootstrapPayloadError("staged image application payload files are not exact")
if app_target.stat().st_size != bundle.stat().st_size or _sha256(app_target) != _sha256(bundle):
raise ImageBootstrapPayloadError("staged image application payload differs from the build bundle")
if not kernel_target.is_dir() or {path.name for path in kernel_target.iterdir()} != KERNEL_FILES:
raise ImageBootstrapPayloadError("staged image kernel payload files are not exact")
for name in ("SHA256SUMS", "METADATA.json"):
if (kernel_target / name).read_bytes() != (dependency / name).read_bytes():
raise ImageBootstrapPayloadError(f"staged image kernel {name} differs from the dependency")
actual_kernel = verify_kernel_dependency(kernel_target)
if actual_kernel != expected_kernel:
raise ImageBootstrapPayloadError("staged image kernel payload identity changed")
return {
"target": TARGET_RELATIVE.as_posix(),
"bundle_path": (TARGET_RELATIVE / APP_RELATIVE).as_posix(),
"bundle_bytes": app_target.stat().st_size,
"bundle_sha256": _sha256(app_target),
"kernel": asdict(actual_kernel),
}
def stage_payload(
root: Path,
bundle: Path,
dependency: Path,
*,
reserve_bytes: int = DEFAULT_RESERVE_BYTES,
) -> dict[str, object]:
root = _root(root)
bundle = Path(bundle).resolve()
dependency = Path(dependency).resolve()
if not bundle.is_file():
raise ImageBootstrapPayloadError("image application bundle is missing")
if type(reserve_bytes) is not int or reserve_bytes < 0:
raise ImageBootstrapPayloadError("reserve_bytes must be a non-negative integer")
verify_kernel_dependency(dependency)
payload_bytes = bundle.stat().st_size + sum((dependency / name).stat().st_size for name in KERNEL_FILES)
if shutil.disk_usage(root).free < payload_bytes + reserve_bytes:
raise ImageBootstrapPayloadError("copied image root filesystem lacks room for both offline payloads")
target = root / TARGET_RELATIVE
if target.exists():
raise ImageBootstrapPayloadError("image bootstrap payload target already exists")
created = False
try:
(target / APP_RELATIVE.parent).mkdir(parents=True, mode=0o755)
(target / KERNEL_RELATIVE).mkdir(mode=0o755)
created = True
shutil.copy2(bundle, target / APP_RELATIVE)
for name in KERNEL_FILES:
shutil.copy2(dependency / name, target / KERNEL_RELATIVE / name)
return verify_staged_payload(root, bundle, dependency)
except BaseException:
if created:
shutil.rmtree(target, ignore_errors=True)
raise
def main() -> int:
parser = argparse.ArgumentParser(description="Stage or verify rootfs image bootstrap payloads")
parser.add_argument("action", choices=("stage", "verify"))
parser.add_argument("--root", type=Path, required=True)
parser.add_argument("--bundle", type=Path, required=True)
parser.add_argument("--dependency", type=Path, required=True)
parser.add_argument("--reserve-bytes", type=int, default=DEFAULT_RESERVE_BYTES)
args = parser.parse_args()
if args.action == "stage":
result = stage_payload(args.root, args.bundle, args.dependency, reserve_bytes=args.reserve_bytes)
else:
result = verify_staged_payload(args.root, args.bundle, args.dependency)
print(json.dumps(result, ensure_ascii=False, sort_keys=True, indent=2))
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except (ImageBootstrapPayloadError, KernelArtifactError) as exc:
print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False))
raise SystemExit(1)
@@ -0,0 +1,166 @@
from __future__ import annotations
import hashlib
import ipaddress
import json
from pathlib import Path
import re
import struct
from typing import Any
PRODUCT_ID = "matrix-screen-controller-walnutpi"
CONFIG_FILE_BYTES = 64 * 1024
HEADER_BYTES = 4096
SLOT_BYTES = (CONFIG_FILE_BYTES - HEADER_BYTES) // 2
FILE_MAGIC = b"MSCCFG2\0"
SLOT_MAGIC = b"MSCSLOT\0"
FORMAT_VERSION = 1
_HEADER = struct.Struct("<8sII")
_SLOT_HEADER = struct.Struct("<8sQI32s")
_USERNAME = re.compile(r"^[a-z_][a-z0-9_-]{0,31}$")
class ImageConfigError(ValueError):
pass
def validate_config(value: Any) -> dict[str, Any]:
if not isinstance(value, dict) or set(value) != {
"schema_version",
"product",
"software_version",
"account",
"wifi",
"ipv4",
}:
raise ImageConfigError("image configuration fields are invalid")
if value["schema_version"] != 1 or value["product"] != PRODUCT_ID:
raise ImageConfigError("image configuration product or schema is unsupported")
if not isinstance(value["software_version"], str) or not re.fullmatch(
r"(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)",
value["software_version"],
):
raise ImageConfigError("software_version must use MAJOR.MINOR.PATCH")
account = value["account"]
if not isinstance(account, dict) or set(account) != {"username", "password"}:
raise ImageConfigError("account configuration is invalid")
if not isinstance(account["username"], str) or not _USERNAME.fullmatch(account["username"]):
raise ImageConfigError("account username is not a supported Linux username")
_validate_secret(account["password"], "account password", minimum=8, maximum=128)
wifi = value["wifi"]
if not isinstance(wifi, dict) or set(wifi) != {"ssid", "password"}:
raise ImageConfigError("Wi-Fi configuration is invalid")
_validate_text(wifi["ssid"], "Wi-Fi SSID", minimum=1, maximum_bytes=32)
_validate_secret(wifi["password"], "Wi-Fi password", minimum=8, maximum=63)
ipv4 = value["ipv4"]
if not isinstance(ipv4, dict) or set(ipv4) != {
"mode",
"address",
"prefix",
"gateway",
"dns",
}:
raise ImageConfigError("IPv4 configuration is invalid")
if ipv4["mode"] not in {"dhcp", "static"}:
raise ImageConfigError("IPv4 mode must be dhcp or static")
if ipv4["mode"] == "dhcp":
if ipv4 != {"mode": "dhcp", "address": "", "prefix": 0, "gateway": "", "dns": []}:
raise ImageConfigError("DHCP configuration must not include static fields")
else:
try:
address = ipaddress.IPv4Address(ipv4["address"])
gateway = ipaddress.IPv4Address(ipv4["gateway"])
except (ipaddress.AddressValueError, TypeError) as exc:
raise ImageConfigError("static IPv4 address or gateway is invalid") from exc
prefix = ipv4["prefix"]
if type(prefix) is not int or not 1 <= prefix <= 32:
raise ImageConfigError("static IPv4 prefix must be between 1 and 32")
network = ipaddress.IPv4Network(f"{address}/{prefix}", strict=False)
if gateway not in network:
raise ImageConfigError("static IPv4 gateway must be in the same subnet")
if not isinstance(ipv4["dns"], list) or not 1 <= len(ipv4["dns"]) <= 4:
raise ImageConfigError("static IPv4 DNS must contain one to four addresses")
try:
ipv4["dns"] = [str(ipaddress.IPv4Address(item)) for item in ipv4["dns"]]
except (ipaddress.AddressValueError, TypeError) as exc:
raise ImageConfigError("static IPv4 DNS contains an invalid address") from exc
return value
def _validate_text(value: Any, label: str, *, minimum: int, maximum_bytes: int) -> None:
if not isinstance(value, str) or len(value) < minimum or len(value.encode("utf-8")) > maximum_bytes:
raise ImageConfigError(f"{label} length is invalid")
if any(character in value for character in ("\0", "\r", "\n")):
raise ImageConfigError(f"{label} contains a forbidden character")
def _validate_secret(value: Any, label: str, *, minimum: int, maximum: int) -> None:
if not isinstance(value, str) or not minimum <= len(value) <= maximum:
raise ImageConfigError(f"{label} length is invalid")
if any(character in value for character in ("\0", "\r", "\n")):
raise ImageConfigError(f"{label} contains a forbidden character")
def _slot_offset(index: int) -> int:
return HEADER_BYTES + index * SLOT_BYTES
def _encode_slot(config: dict[str, Any], generation: int) -> bytes:
payload = (json.dumps(validate_config(config), ensure_ascii=False, sort_keys=True, separators=(",", ":")) + "\n").encode("utf-8")
maximum = SLOT_BYTES - _SLOT_HEADER.size
if len(payload) > maximum:
raise ImageConfigError("image configuration payload is too large")
header = _SLOT_HEADER.pack(SLOT_MAGIC, generation, len(payload), hashlib.sha256(payload).digest())
return header + payload + bytes(SLOT_BYTES - len(header) - len(payload))
def create_config_file(config: dict[str, Any]) -> bytes:
result = bytearray(CONFIG_FILE_BYTES)
_HEADER.pack_into(result, 0, FILE_MAGIC, FORMAT_VERSION, CONFIG_FILE_BYTES)
result[_slot_offset(0) : _slot_offset(0) + SLOT_BYTES] = _encode_slot(config, 1)
return bytes(result)
def read_config_file(data: bytes) -> tuple[dict[str, Any], int, int]:
if len(data) != CONFIG_FILE_BYTES:
raise ImageConfigError("image configuration file size is invalid")
magic, version, declared_size = _HEADER.unpack_from(data, 0)
if magic != FILE_MAGIC or version != FORMAT_VERSION or declared_size != CONFIG_FILE_BYTES:
raise ImageConfigError("image configuration header is invalid")
valid: list[tuple[int, int, dict[str, Any]]] = []
for index in range(2):
offset = _slot_offset(index)
slot_magic, generation, payload_bytes, digest = _SLOT_HEADER.unpack_from(data, offset)
if slot_magic != SLOT_MAGIC or payload_bytes <= 0 or payload_bytes > SLOT_BYTES - _SLOT_HEADER.size:
continue
payload = data[offset + _SLOT_HEADER.size : offset + _SLOT_HEADER.size + payload_bytes]
if hashlib.sha256(payload).digest() != digest:
continue
try:
document = validate_config(json.loads(payload.decode("utf-8")))
except (UnicodeError, json.JSONDecodeError, ImageConfigError):
continue
valid.append((generation, index, document))
if not valid:
raise ImageConfigError("image configuration has no valid slot")
generation, index, document = max(valid, key=lambda item: (item[0], item[1]))
return document, generation, index
def update_config_file(data: bytes, config: dict[str, Any]) -> bytes:
_current, generation, active = read_config_file(data)
target = 1 - active
result = bytearray(data)
offset = _slot_offset(target)
result[offset : offset + SLOT_BYTES] = _encode_slot(config, generation + 1)
return bytes(result)
def read_config_path(path: Path) -> dict[str, Any]:
document, _generation, _slot = read_config_file(Path(path).read_bytes())
return document
@@ -0,0 +1,170 @@
#!/bin/bash
set -Eeuo pipefail
STATUS=/boot/MSCSTAT.TXT
PACKAGE_LOG=/boot/MSCPKG.TXT
PACKAGE_INVENTORY=/boot/MSCPKGS.TSV
DEPLOY_LOG=/boot/MSCDEPLOY.TXT
CONFIG=/boot/MSCCFG.BIN
BUNDLE=/opt/matrix-image-bootstrap/app/MSCBOOT.TGZ
META=/boot/MSCMETA.JSN
WORK=/run/matrix-image-provision
KERNEL_ROOT=/opt/matrix-image-bootstrap/axp313a
KERNEL_ARCHIVE=$KERNEL_ROOT/axp313a-kernel-6.1.31-matrix-axp313a1.tar.gz
KERNEL_METADATA=$KERNEL_ROOT/METADATA.json
KERNEL_WORK=/var/tmp/matrix-axp313a-image-install
status() {
printf '%s\n' "$1" > "$STATUS"
sync "$STATUS" || true
}
failed() {
code=$?
trap - ERR
rm -rf -- /var/tmp/matrix-axp313a-image-install
status "FAILED: stage=${STAGE:-starting}; code=$code; line=${BASH_LINENO[0]:-unknown}. Power off, remove the TF card, and inspect this file on Windows."
exit "$code"
}
trap failed ERR
if [ "$(id -u)" -ne 0 ]; then
status "FAILED: the one-time provisioner did not run as root."
exit 1
fi
STAGE=validate
status "RUNNING: validating the offline image payload."
rm -rf -- "$WORK"
install -d -m 0700 "$WORK"
python3 - "$META" "$BUNDLE" "$KERNEL_ARCHIVE" "$KERNEL_METADATA" <<'PY'
import hashlib
import json
from pathlib import Path
import sys
meta = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
bundle = Path(sys.argv[2])
kernel_artifact = Path(sys.argv[3])
kernel = json.loads(Path(sys.argv[4]).read_text(encoding="utf-8"))
assert meta == {
"format_version": 3,
"product": "matrix-screen-controller-walnutpi",
"software_version": meta["software_version"],
"bundle_path": "/opt/matrix-image-bootstrap/app/MSCBOOT.TGZ",
"bundle_bytes": meta["bundle_bytes"],
"bundle_sha256": meta["bundle_sha256"],
"kernel_release": "6.1.31-matrix-axp313a1",
"kernel_artifact": "axp313a-kernel-6.1.31-matrix-axp313a1.tar.gz",
"kernel_artifact_bytes": meta["kernel_artifact_bytes"],
"kernel_artifact_sha256": meta["kernel_artifact_sha256"],
"kernel_unpacked_file_bytes": meta["kernel_unpacked_file_bytes"],
"boot_required_bytes": meta["boot_required_bytes"],
"boot_safety_bytes": 32 * 1024 * 1024,
}
assert isinstance(meta["boot_required_bytes"], int) and meta["boot_required_bytes"] > 0
assert bundle.stat().st_size == meta["bundle_bytes"]
digest = hashlib.sha256()
with bundle.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
assert digest.hexdigest() == meta["bundle_sha256"]
assert kernel["schema_version"] == 1
assert kernel["architecture"] == "aarch64"
assert kernel["kernel_release"] == meta["kernel_release"]
assert kernel["artifact"] == meta["kernel_artifact"]
assert kernel["artifact_bytes"] == meta["kernel_artifact_bytes"] == kernel_artifact.stat().st_size
assert kernel["artifact_sha256"] == meta["kernel_artifact_sha256"]
assert kernel["unpacked_file_bytes"] == meta["kernel_unpacked_file_bytes"]
digest = hashlib.sha256()
with kernel_artifact.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
assert digest.hexdigest() == meta["kernel_artifact_sha256"]
PY
tar -xzf "$BUNDLE" -C "$WORK"
cd "$WORK/project/核桃派软件源代码"
PYTHONPATH=. python3 scripts/kernel_artifact.py --kernel "$KERNEL_ROOT" >/dev/null
cd "$WORK/project/离线依赖/其他依赖/aarch64-py311"
sha256sum -c SHA256SUMS
cd "$WORK/project/离线依赖/其他依赖/debian12-aarch64"
sha256sum -c SHA256SUMS
STAGE=packages
status "RUNNING: installing offline Debian packages."
export DEBIAN_FRONTEND=noninteractive
dpkg-query -W -f='${db:Status-Abbrev}\t${binary:Package}\t${Version}\n' >"$PACKAGE_INVENTORY"
if ! dpkg -i "$WORK"/project/离线依赖/其他依赖/debian12-aarch64/*.deb >"$PACKAGE_LOG" 2>&1; then
status "FAILED: stage=packages; offline Debian package installation failed. Inspect MSCPKG.TXT on Windows; it contains package diagnostics but no configured credentials."
exit 1
fi
rm -f -- "$PACKAGE_LOG" "$PACKAGE_INVENTORY"
STAGE=network
status "RUNNING: applying the requested network configuration."
cd "$WORK/project/核桃派软件源代码"
PYTHONPATH=. python3 -m scripts.provision_device network --config "$CONFIG"
systemctl restart NetworkManager.service
STAGE=deploy
status "RUNNING: installing and testing matrix-screen-controller."
{
if [ -e /opt/matrix-screen-controller ]; then
printf '%s\n' 'A previous deployment attempt left /opt/matrix-screen-controller in place.'
systemctl --no-pager --full status matrix-screen-controller.service || true
journalctl --no-pager -u matrix-screen-controller.service -n 120 || true
fi
} >"$DEPLOY_LOG" 2>&1
FRPC_RUN_USER=$(PYTHONPATH=. python3 -c 'from pathlib import Path; from scripts.image_config import read_config_path; import sys; print(read_config_path(Path(sys.argv[1]))["account"]["username"])' "$CONFIG")
if ! FRPC_RUN_USER="$FRPC_RUN_USER" DEFER_SERVICE_START=1 WHEELHOUSE="$WORK/project/离线依赖/其他依赖/aarch64-py311" FRPC_BUNDLE="$WORK/project/离线依赖/其他依赖/frp/0.71.0/linux-arm64" ./scripts/deploy_walnutpi.sh >>"$DEPLOY_LOG" 2>&1; then
status "FAILED: stage=deploy; controller deployment failed. Inspect MSCDEPLOY.TXT on Windows; it contains deployment diagnostics but no configured credentials."
exit 1
fi
rm -f -- "$DEPLOY_LOG"
STAGE=account
status "RUNNING: creating the configured account and unique device identity."
PYTHONPATH=. python3 -m scripts.provision_device account --config "$CONFIG"
FRPC_RUN_USER="$FRPC_RUN_USER" DEFER_SERVICE_START=1 FRPC_BUNDLE="$WORK/project/离线依赖/其他依赖/frp/0.71.0/linux-arm64" /bin/sh ./scripts/install_frpc_system.sh
systemctl disable boot_script.service >/dev/null 2>&1 || true
PYTHONPATH=. python3 -m scripts.provision_device identity --config "$CONFIG"
STAGE=ssh
status "RUNNING: enabling password-authenticated SSH for the configured administrator."
PYTHONPATH=. python3 -m scripts.provision_device ssh --config "$CONFIG"
STAGE=kernel
status "RUNNING: installing the verified offline AXP313A kernel."
kernel_unpacked_bytes=$(python3 -c 'import json; print(json.load(open("/opt/matrix-image-bootstrap/axp313a/METADATA.json", encoding="utf-8"))["unpacked_file_bytes"])')
available_bytes=$(df -Pk /var/tmp | awk 'NR == 2 { printf "%.0f\n", $4 * 1024 }')
required_bytes=$((kernel_unpacked_bytes * 2 + 268435456))
if [ "$available_bytes" -lt "$required_bytes" ]; then
printf '%s\n' 'insufficient root filesystem space to install the offline kernel safely' >&2
exit 1
fi
rm -rf -- "$KERNEL_WORK"
install -d -m 0700 "$KERNEL_WORK"
tar -xzf "$KERNEL_ARCHIVE" -C "$KERNEL_WORK"
cd "$WORK/project/核桃派软件源代码"
./scripts/install_axp313a_kernel.sh --artifact "$KERNEL_WORK"
rm -rf -- "$KERNEL_WORK"
rm -rf -- /opt/matrix-image-bootstrap
STAGE=finish
install -d -m 0711 /var/lib/matrix-screen-controller
printf '%s\n' "$(cat VERSION)" > /var/lib/matrix-screen-controller/.factory-image-version
if command -v shred >/dev/null 2>&1; then
shred -n 1 -z "$CONFIG" || true
fi
rm -f -- "$CONFIG" "$META" /boot/MSCINIT
systemctl disable matrix-image-firstboot.service >/dev/null 2>&1 || true
rm -f -- /etc/systemd/system/matrix-image-firstboot.service
rm -rf -- "$WORK"
sync
if nmcli -t -f NAME connection show --active 2>/dev/null | grep -Fxq 'matrix-screen' \
&& ip route show default | grep -q .; then
status "SUCCESS: provisioning completed; the device is rebooting into the installed controller."
else
status "SUCCESS: provisioning completed while Wi-Fi is not connected; the installed controller will keep trying after reboot."
fi
systemctl reboot
@@ -0,0 +1,95 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
from dataclasses import asdict
import json
from pathlib import Path
import shutil
from scripts.kernel_artifact import ARTIFACT_NAME, KernelArtifactError, verify_kernel_dependency
TARGET_RELATIVE = Path("opt/matrix-image-bootstrap/axp313a")
DEFAULT_RESERVE_BYTES = 256 * 1024 * 1024
EXPECTED_FILES = {ARTIFACT_NAME, "SHA256SUMS", "METADATA.json"}
class ImageKernelPayloadError(ValueError):
pass
def _root(path: Path) -> Path:
resolved = Path(path).resolve()
if not resolved.is_dir() or resolved == Path(resolved.anchor):
raise ImageKernelPayloadError("image root must be an explicit mounted or isolated directory")
return resolved
def verify_staged_payload(root: Path, dependency: Path) -> dict[str, object]:
root = _root(root)
dependency = Path(dependency).resolve()
expected = verify_kernel_dependency(dependency)
target = root / TARGET_RELATIVE
if not target.is_dir() or {path.name for path in target.iterdir()} != EXPECTED_FILES:
raise ImageKernelPayloadError("staged image kernel payload files are not exact")
for name in ("SHA256SUMS", "METADATA.json"):
if (target / name).read_bytes() != (dependency / name).read_bytes():
raise ImageKernelPayloadError(f"staged image kernel {name} differs from the dependency")
actual = verify_kernel_dependency(target)
if actual != expected:
raise ImageKernelPayloadError("staged image kernel payload identity changed")
return {"target": TARGET_RELATIVE.as_posix(), **asdict(actual)}
def stage_payload(
root: Path,
dependency: Path,
*,
reserve_bytes: int = DEFAULT_RESERVE_BYTES,
) -> dict[str, object]:
root = _root(root)
dependency = Path(dependency).resolve()
if type(reserve_bytes) is not int or reserve_bytes < 0:
raise ImageKernelPayloadError("reserve_bytes must be a non-negative integer")
info = verify_kernel_dependency(dependency)
required = info.artifact_bytes + reserve_bytes
if shutil.disk_usage(root).free < required:
raise ImageKernelPayloadError("copied image root filesystem lacks room for the offline kernel payload")
target = root / TARGET_RELATIVE
if target.exists():
raise ImageKernelPayloadError("image kernel payload target already exists")
created = False
try:
target.mkdir(parents=True, mode=0o755)
created = True
for name in EXPECTED_FILES:
shutil.copy2(dependency / name, target / name)
return verify_staged_payload(root, dependency)
except BaseException:
if created:
shutil.rmtree(target, ignore_errors=True)
raise
def main() -> int:
parser = argparse.ArgumentParser(description="Stage or verify the offline kernel in a copied IMG root")
parser.add_argument("action", choices=("stage", "verify"))
parser.add_argument("--root", type=Path, required=True)
parser.add_argument("--dependency", type=Path, required=True)
parser.add_argument("--reserve-bytes", type=int, default=DEFAULT_RESERVE_BYTES)
args = parser.parse_args()
if args.action == "stage":
result = stage_payload(args.root, args.dependency, reserve_bytes=args.reserve_bytes)
else:
result = verify_staged_payload(args.root, args.dependency)
print(json.dumps(result, ensure_ascii=False, sort_keys=True, indent=2))
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except (ImageKernelPayloadError, KernelArtifactError) as exc:
print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False))
raise SystemExit(1)
@@ -0,0 +1,170 @@
#!/bin/sh
set -eu
ARTIFACT=
while [ "$#" -gt 0 ]; do
case "$1" in
--artifact) ARTIFACT=$2; shift 2 ;;
*) echo "unknown argument: $1" >&2; exit 2 ;;
esac
done
if [ "$(id -u)" -ne 0 ] || [ "$(uname -m)" != "aarch64" ]; then
echo "installer must run as root on the WalnutPi AArch64 host" >&2
exit 1
fi
if [ -z "$ARTIFACT" ]; then
echo "usage: install_axp313a_kernel.sh --artifact DIR" >&2
exit 2
fi
ARTIFACT=$(CDPATH= cd -- "$ARTIFACT" && pwd)
SOURCE_ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
RELEASE=6.1.31-matrix-axp313a1
MODULE_TARGET=/lib/modules/$RELEASE
IMAGE_TARGET=/boot/Image-matrix-axp313a1
DTB_TARGET=/boot/sun50i-h616-walnutpi-1b-matrix-axp313a1.dtb
EMMC_DTB_TARGET=/boot/sun50i-h616-walnutpi-1b-emmc-matrix-axp313a1.dtb
BOOT_CMD=/boot/boot.cmd
BOOT_SCR=/boot/boot.scr
ORIGINAL_CMD=/boot/boot.cmd.matrix-original
ORIGINAL_SCR=/boot/boot.scr.matrix-original
COMMITTED=0
BOOT_REPLACED=0
SYSTEM_FILES_INSTALLED=0
cleanup_failed_install() {
code=$?
trap - EXIT HUP INT TERM
if [ "$code" -ne 0 ] && [ "$COMMITTED" -eq 0 ]; then
if [ "$BOOT_REPLACED" -eq 1 ] && [ -f "$ORIGINAL_CMD" ] && [ -f "$ORIGINAL_SCR" ]; then
install -m 0644 "$ORIGINAL_CMD" "$BOOT_CMD"
install -m 0644 "$ORIGINAL_SCR" "$BOOT_SCR"
fi
rm -f -- "$IMAGE_TARGET" "$DTB_TARGET" "$EMMC_DTB_TARGET" \
"/boot/System.map-$RELEASE" "/boot/config-$RELEASE" /boot/matrix-kernel-good
case "$MODULE_TARGET" in /lib/modules/6.1.31-matrix-axp313a1) rm -rf -- "$MODULE_TARGET" ;; esac
if [ "$SYSTEM_FILES_INSTALLED" -eq 1 ]; then
rm -f -- /etc/systemd/system/matrix-axp313a-health.service \
/etc/systemd/system/matrix-axp313a-rollback.service \
/opt/matrix-screen-controller-system/axp313a_kernel_health.py
fi
systemctl daemon-reload >/dev/null 2>&1 || true
sync
fi
exit "$code"
}
trap cleanup_failed_install EXIT HUP INT TERM
if [ ! -f "$ARTIFACT/SHA256SUMS" ]; then
echo "candidate artifact manifest is missing" >&2
exit 1
fi
(cd "$ARTIFACT" && sha256sum -c SHA256SUMS)
for path in \
"$ARTIFACT/boot/Image-matrix-axp313a1" \
"$ARTIFACT/boot/sun50i-h616-walnutpi-1b-matrix-axp313a1.dtb" \
"$ARTIFACT/boot/sun50i-h616-walnutpi-1b-emmc-matrix-axp313a1.dtb" \
"$ARTIFACT/lib/modules/$RELEASE"
do
if [ ! -e "$path" ]; then echo "candidate artifact is incomplete: $path" >&2; exit 1; fi
done
if [ -e "$MODULE_TARGET" ] || [ -e "$IMAGE_TARGET" ] || [ -e "$DTB_TARGET" ] || [ -e "$EMMC_DTB_TARGET" ]; then
echo "candidate kernel paths already exist; refusing to overwrite" >&2
exit 1
fi
if [ ! -f "$BOOT_CMD" ] || [ ! -f "$BOOT_SCR" ]; then
echo "original WalnutPi boot files are missing" >&2
exit 1
fi
command -v mkimage >/dev/null
command -v fdtget >/dev/null
command -v strings >/dev/null
# Refuse before the first candidate boot write unless the complete candidate,
# rollback set, temporary scripts and fixed safety reserve all fit.
PYTHONPATH="$SOURCE_ROOT" python3 "$SOURCE_ROOT/scripts/boot_space.py" \
--boot-root /boot --artifact-root "$ARTIFACT"
if [ "$(cat "$ARTIFACT/KERNEL_RELEASE" 2>/dev/null || true)" != "$RELEASE" ]; then
echo "candidate kernel release marker is wrong" >&2
exit 1
fi
if [ "$(cat "$ARTIFACT/SOURCE_COMMIT" 2>/dev/null || true)" != \
"30ff3fd5cf45417622b447a12e7a947402ffe34d" ]; then
echo "candidate vendor source commit marker is wrong" >&2
exit 1
fi
if ! strings "$ARTIFACT/boot/Image-matrix-axp313a1" | \
grep -Fq "Linux version $RELEASE"; then
echo "candidate Image does not contain the expected release" >&2
exit 1
fi
verify_dtb() {
dtb=$1
pmic=/soc/i2c@7081400/pmic@36
regulators=$pmic/regulators
if [ "$(fdtget -t s "$dtb" "$pmic" compatible)" != "x-powers,axp313a" ]; then
echo "candidate DTB does not use the exact AXP313A compatible: $dtb" >&2
return 1
fi
for specification in \
"aldo1 1800000 1800000 vcc1v8" \
"dldo1 3300000 3300000 vcc3v3" \
"dcdc1 810000 1160000 vdd-gpu-sys" \
"dcdc2 810000 1080000 vdd-cpu" \
"dcdc3 1100000 1100000 vdd-dram"
do
set -- $specification
node=$regulators/$1
[ "$(fdtget -t i "$dtb" "$node" regulator-min-microvolt)" = "$2" ]
[ "$(fdtget -t i "$dtb" "$node" regulator-max-microvolt)" = "$3" ]
[ "$(fdtget -t s "$dtb" "$node" regulator-name)" = "$4" ]
fdtget "$dtb" "$node" regulator-always-on >/dev/null
done
cpu_supply=$(fdtget -t x "$dtb" /cpus/cpu@0 cpu-supply)
dcdc2_phandle=$(fdtget -t x "$dtb" "$regulators/dcdc2" phandle)
if [ "$cpu_supply" != "$dcdc2_phandle" ]; then
echo "candidate DTB CPU0 supply is not DCDC2: $dtb" >&2
return 1
fi
}
verify_dtb "$ARTIFACT/boot/sun50i-h616-walnutpi-1b-matrix-axp313a1.dtb"
verify_dtb "$ARTIFACT/boot/sun50i-h616-walnutpi-1b-emmc-matrix-axp313a1.dtb"
install -m 0644 "$ARTIFACT/boot/Image-matrix-axp313a1" "$IMAGE_TARGET"
install -m 0644 "$ARTIFACT/boot/sun50i-h616-walnutpi-1b-matrix-axp313a1.dtb" "$DTB_TARGET"
install -m 0644 "$ARTIFACT/boot/sun50i-h616-walnutpi-1b-emmc-matrix-axp313a1.dtb" "$EMMC_DTB_TARGET"
install -m 0644 "$ARTIFACT/boot/System.map-$RELEASE" "/boot/System.map-$RELEASE"
install -m 0644 "$ARTIFACT/boot/config-$RELEASE" "/boot/config-$RELEASE"
cp -a -- "$ARTIFACT/lib/modules/$RELEASE" "$MODULE_TARGET"
depmod "$RELEASE"
if [ ! -f "$ORIGINAL_CMD" ]; then install -m 0644 "$BOOT_CMD" "$ORIGINAL_CMD"; fi
if [ ! -f "$ORIGINAL_SCR" ]; then install -m 0644 "$BOOT_SCR" "$ORIGINAL_SCR"; fi
sha256sum "$ORIGINAL_CMD" "$ORIGINAL_SCR" > /boot/matrix-original.SHA256SUMS
install -d -m 0755 /opt/matrix-screen-controller-system
install -m 0755 "$SOURCE_ROOT/scripts/axp313a_kernel_health.py" \
/opt/matrix-screen-controller-system/axp313a_kernel_health.py
install -m 0644 "$SOURCE_ROOT/systemd/matrix-axp313a-health.service" \
/etc/systemd/system/matrix-axp313a-health.service
install -m 0644 "$SOURCE_ROOT/systemd/matrix-axp313a-rollback.service" \
/etc/systemd/system/matrix-axp313a-rollback.service
SYSTEM_FILES_INSTALLED=1
cmd_tmp=$(mktemp /boot/.boot.cmd.matrix.XXXXXX)
scr_tmp=$(mktemp /boot/.boot.scr.matrix.XXXXXX)
python3 "$SOURCE_ROOT/scripts/render_dual_kernel_boot.py" "$ORIGINAL_CMD" "$cmd_tmp"
mkimage -C none -A arm -T script -d "$cmd_tmp" "$scr_tmp" >/dev/null
install -m 0644 "$cmd_tmp" "$BOOT_CMD"
install -m 0644 "$scr_tmp" "$BOOT_SCR"
BOOT_REPLACED=1
rm -f -- "$cmd_tmp" "$scr_tmp"
printf '%s\n' "$RELEASE" > /boot/matrix-kernel-good
sync
systemctl daemon-reload
systemctl enable matrix-axp313a-health.service >/dev/null
COMMITTED=1
trap - EXIT HUP INT TERM
echo "AXP313A candidate installed; reboot is required"
@@ -0,0 +1,105 @@
#!/bin/sh
set -eu
if [ "$(id -u)" -ne 0 ]; then
echo "install_frpc_system.sh must run as root" >&2
exit 1
fi
if [ "$(uname -m)" != "aarch64" ]; then
echo "frpc system payload is only for AArch64" >&2
exit 1
fi
SOURCE_ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
PROJECT_ROOT=$(CDPATH= cd -- "$SOURCE_ROOT/.." && pwd)
BUNDLE=${FRPC_BUNDLE:-$PROJECT_ROOT/发布更新相关/其他依赖/frp/0.71.0/linux-arm64}
UNIT_SOURCE=$SOURCE_ROOT/systemd/matrix-screen-frpc.service
UNIT=/etc/systemd/system/matrix-screen-frpc.service
DROPIN_DIR=/etc/systemd/system/matrix-screen-frpc.service.d
DROPIN=$DROPIN_DIR/user.conf
BINARY=/usr/local/bin/frpc
SERVICE=matrix-screen-frpc.service
STATE_ROOT=/var/lib/matrix-screen-controller/frp
if [ ! -f "$BUNDLE/SHA256SUMS" ] || [ ! -f "$BUNDLE/frpc" ] || [ ! -f "$BUNDLE/LICENSE" ]; then
echo "verified frpc offline bundle is incomplete: $BUNDLE" >&2
exit 1
fi
(cd "$BUNDLE" && sha256sum -c SHA256SUMS)
RUN_USER=${FRPC_RUN_USER:-${SUDO_USER:-}}
if [ -z "$RUN_USER" ] && [ -f "$DROPIN" ]; then
RUN_USER=$(sed -n 's/^User=//p' "$DROPIN" | head -n 1)
fi
if [ -z "$RUN_USER" ]; then
RUN_USER=$(python3 -c 'import sys; sys.path.insert(0, sys.argv[1]); from scripts.ota_components import account; print(account())' "$SOURCE_ROOT")
fi
case "$RUN_USER" in
''|root|*[!a-zA-Z0-9_-]*)
echo "FRPC_RUN_USER must identify the non-root maintenance account" >&2
exit 1
;;
esac
if ! getent passwd "$RUN_USER" >/dev/null 2>&1 && [ "${DEFER_SERVICE_START:-0}" != "1" ]; then
echo "FRP maintenance account does not exist" >&2
exit 1
fi
RUN_GROUP=$RUN_USER
if getent passwd "$RUN_USER" >/dev/null 2>&1; then RUN_GROUP=$(id -gn "$RUN_USER"); fi
binary_matches=0
if [ -f "$BINARY" ] && [ ! -L "$BINARY" ] && cmp -s "$BUNDLE/frpc" "$BINARY" && [ "$(stat -c %a:%u:%g "$BINARY")" = 755:0:0 ]; then binary_matches=1; fi
unit_matches=0
if [ -f "$DROPIN" ] && cmp -s "$UNIT_SOURCE" "$UNIT" && [ "$(stat -c %a:%u:%g "$UNIT")" = 644:0:0 ] && [ "$(stat -c %a:%u:%g "$DROPIN")" = 644:0:0 ] && [ "$(cat "$DROPIN")" = "$(printf '[Service]\nUser=%s\nGroup=%s\n' "$RUN_USER" "$RUN_GROUP")" ]; then unit_matches=1; fi
was_active=0
was_enabled=0
systemctl is-active --quiet "$SERVICE" 2>/dev/null && was_active=1 || true
systemctl is-enabled --quiet "$SERVICE" 2>/dev/null && was_enabled=1 || true
if [ "$was_active" -eq 1 ] && { [ "$binary_matches" -eq 0 ] || [ "$unit_matches" -eq 0 ]; }; then systemctl stop "$SERVICE"; fi
install -d -m 0755 /usr/local/bin
temporary=/usr/local/bin/.frpc.install.$$
cleanup() {
case "$temporary" in
/usr/local/bin/.frpc.install.*) rm -f -- "$temporary" ;;
esac
}
trap cleanup EXIT HUP INT TERM
if [ "$binary_matches" -eq 0 ]; then
install -m 0755 "$BUNDLE/frpc" "$temporary"
"$temporary" --version | grep -Fx '0.71.0' >/dev/null
mv -f -- "$temporary" "$BINARY"
else
"$BINARY" --version | grep -Fx '0.71.0' >/dev/null
echo "frpc binary already verified; skipped"
fi
trap - EXIT HUP INT TERM
if [ "$unit_matches" -eq 0 ]; then
install -m 0644 "$UNIT_SOURCE" "$UNIT"
install -d -m 0755 "$DROPIN_DIR"
printf '[Service]\nUser=%s\nGroup=%s\n' "$RUN_USER" "$RUN_GROUP" > "$DROPIN"
chmod 0644 "$DROPIN"
fi
if getent passwd "$RUN_USER" >/dev/null 2>&1; then
RUN_GROUP=$(id -gn "$RUN_USER")
# Local accounts may only traverse (not list) the persistent root; the
# FRP subtree itself remains restricted to root and the maintenance group.
chmod 0711 "$(dirname -- "$STATE_ROOT")"
install -d -o root -g "$RUN_GROUP" -m 2750 "$STATE_ROOT"
install -d -o root -g "$RUN_GROUP" -m 2750 "$STATE_ROOT/profiles"
find "$STATE_ROOT" -type d -exec chown root:"$RUN_GROUP" {} \; -exec chmod 2750 {} \;
find "$STATE_ROOT" -type f -exec chown root:"$RUN_GROUP" {} \; -exec chmod 0640 {} \;
if [ -f "$STATE_ROOT/active.env" ]; then chmod 0600 "$STATE_ROOT/active.env"; fi
elif [ "${DEFER_SERVICE_START:-0}" != "1" ]; then
echo "FRP maintenance account does not exist: $RUN_USER" >&2
exit 1
fi
if [ "$unit_matches" -eq 0 ]; then
systemctl daemon-reload
if [ "$was_enabled" -eq 1 ]; then systemctl enable "$SERVICE" >/dev/null; else systemctl disable "$SERVICE" >/dev/null 2>&1 || true; fi
fi
if [ "$was_active" -eq 1 ] && { [ "$binary_matches" -eq 0 ] || [ "$unit_matches" -eq 0 ]; }; then systemctl start "$SERVICE"; fi
echo "frpc 0.71.0 installed as a system component"
@@ -0,0 +1,82 @@
#!/bin/bash
set -Eeuo pipefail
if [ "$(id -u)" -ne 0 ]; then
printf '%s\n' 'install_image_bootstrap.sh must run as root.' >&2
exit 1
fi
if [ "$#" -ne 5 ]; then
printf '%s\n' 'usage: install_image_bootstrap.sh IMAGE SERVICE_UNIT SSH_CONFIG KERNEL_DEPENDENCY APP_BUNDLE' >&2
exit 2
fi
IMAGE=$(readlink -f -- "$1")
UNIT=$(readlink -f -- "$2")
SSH_CONFIG=$(readlink -f -- "$3")
KERNEL_DEPENDENCY=$(readlink -f -- "$4")
APP_BUNDLE=$(readlink -f -- "$5")
SCRIPT_ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
[ -f "$IMAGE" ]
[ -f "$UNIT" ]
[ -f "$SSH_CONFIG" ]
[ -d "$KERNEL_DEPENDENCY" ]
[ -f "$APP_BUNDLE" ]
KERNEL_ARCHIVE=axp313a-kernel-6.1.31-matrix-axp313a1.tar.gz
[ -f "$KERNEL_DEPENDENCY/$KERNEL_ARCHIVE" ]
[ -f "$KERNEL_DEPENDENCY/SHA256SUMS" ]
[ -f "$KERNEL_DEPENDENCY/METADATA.json" ]
PYTHONPATH="$SCRIPT_ROOT" python3 "$SCRIPT_ROOT/scripts/kernel_artifact.py" \
--kernel "$KERNEL_DEPENDENCY" >/dev/null
LOOP=
MOUNT_DIR=$(mktemp -d /var/tmp/matrix-image-root.XXXXXX)
cleanup() {
if mountpoint -q "$MOUNT_DIR"; then
umount "$MOUNT_DIR"
fi
if [ -n "$LOOP" ]; then
losetup -d "$LOOP"
fi
rmdir "$MOUNT_DIR" 2>/dev/null || true
}
trap cleanup EXIT
LOOP=$(losetup --find --show --partscan "$IMAGE")
ROOT_PARTITION="${LOOP}p2"
for _attempt in $(seq 1 20); do
[ -b "$ROOT_PARTITION" ] && break
sleep 0.25
done
[ -b "$ROOT_PARTITION" ]
mount -o rw "$ROOT_PARTITION" "$MOUNT_DIR"
[ -x "$MOUNT_DIR/usr/sbin/sshd" ]
[ -x "$MOUNT_DIR/usr/bin/sudo" ]
[ -x "$MOUNT_DIR/usr/sbin/visudo" ]
[ -f "$MOUNT_DIR/lib/systemd/system/ssh.service" ]
install -D -m 0644 "$UNIT" "$MOUNT_DIR/etc/systemd/system/matrix-image-firstboot.service"
install -D -m 0644 "$SSH_CONFIG" "$MOUNT_DIR/etc/ssh/sshd_config.d/10-walnutpi-screen-hardening.conf"
install -d -m 0755 "$MOUNT_DIR/etc/systemd/system/multi-user.target.wants"
ln -sfn ../matrix-image-firstboot.service \
"$MOUNT_DIR/etc/systemd/system/multi-user.target.wants/matrix-image-firstboot.service"
systemctl --root="$MOUNT_DIR" unmask ssh.service
systemctl --root="$MOUNT_DIR" enable ssh.service
# The exported image must not clone identity or network state from the baseline.
find "$MOUNT_DIR/etc/NetworkManager/system-connections" -mindepth 1 -maxdepth 1 -type f -delete 2>/dev/null || true
find "$MOUNT_DIR/var/lib/NetworkManager" -mindepth 1 -maxdepth 1 -type f -delete 2>/dev/null || true
rm -f -- "$MOUNT_DIR"/etc/ssh/ssh_host_* "$MOUNT_DIR/etc/machine-id"
: > "$MOUNT_DIR/etc/machine-id"
rm -f -- "$MOUNT_DIR/var/lib/dbus/machine-id"
ln -sfn /etc/machine-id "$MOUNT_DIR/var/lib/dbus/machine-id"
PYTHONPATH="$SCRIPT_ROOT" python3 "$SCRIPT_ROOT/scripts/image_bootstrap_payload.py" stage \
--root "$MOUNT_DIR" --bundle "$APP_BUNDLE" --dependency "$KERNEL_DEPENDENCY" >/dev/null
sync
umount "$MOUNT_DIR"
losetup -d "$LOOP"
LOOP=
printf '%s\n' 'rootfs bootstrap installed'
@@ -0,0 +1,279 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
from dataclasses import asdict, dataclass
import gzip
import hashlib
import json
from pathlib import Path, PurePosixPath
import tarfile
from typing import BinaryIO
KERNEL_RELEASE = "6.1.31-matrix-axp313a1"
SOURCE_COMMIT = "30ff3fd5cf45417622b447a12e7a947402ffe34d"
SOURCE_ARCHIVE_NAME = "walnutpi-linux-6.1.31-30ff3fd5.tar.gz"
SOURCE_ARCHIVE_SHA256 = "8659bb3d64313c4693c3605374167a8145186e5c9d43eb771a52a7359699302f"
ARTIFACT_NAME = "axp313a-kernel-6.1.31-matrix-axp313a1.tar.gz"
MAX_REGULAR_FILES = 4_096
MAX_UNPACKED_BYTES = 512 * 1024 * 1024
REQUIRED_BOOT_FILES = {
"boot/Image-matrix-axp313a1",
"boot/sun50i-h616-walnutpi-1b-matrix-axp313a1.dtb",
"boot/sun50i-h616-walnutpi-1b-emmc-matrix-axp313a1.dtb",
f"boot/System.map-{KERNEL_RELEASE}",
f"boot/config-{KERNEL_RELEASE}",
}
class KernelArtifactError(ValueError):
pass
@dataclass(frozen=True)
class KernelArtifactInfo:
artifact: str
artifact_bytes: int
artifact_sha256: str
kernel_release: str
source_commit: str
unpacked_file_bytes: int
regular_file_count: int
module_file_count: int
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(4 * 1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _load_json(path: Path) -> dict:
try:
document = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
raise KernelArtifactError(f"invalid JSON metadata: {path}") from exc
if not isinstance(document, dict):
raise KernelArtifactError(f"metadata must be an object: {path}")
return document
def _verify_outer_sum(directory: Path, expected_name: str) -> tuple[Path, str]:
manifest = directory / "SHA256SUMS"
if not manifest.is_file():
raise KernelArtifactError(f"SHA256SUMS is missing: {directory}")
try:
lines = [line for line in manifest.read_text(encoding="ascii").splitlines() if line]
except (OSError, UnicodeError) as exc:
raise KernelArtifactError(f"SHA256SUMS is unreadable: {directory}") from exc
if len(lines) != 1 or " " not in lines[0]:
raise KernelArtifactError(f"SHA256SUMS must contain exactly one binary: {directory}")
expected_digest, name = lines[0].split(" ", 1)
if name != expected_name or len(expected_digest) != 64:
raise KernelArtifactError(f"SHA256SUMS entry is wrong: {directory}")
path = directory / name
if not path.is_file():
raise KernelArtifactError(f"dependency binary is missing: {path}")
actual_digest = sha256_file(path)
if actual_digest != expected_digest:
raise KernelArtifactError(f"dependency SHA-256 mismatch: {path}")
return path, actual_digest
def _normalized_member_name(raw: str) -> str:
while raw.startswith("./"):
raw = raw[2:]
pure = PurePosixPath(raw)
if not raw or raw == "." or pure.is_absolute() or ".." in pure.parts or "\\" in raw:
if raw == ".":
return ""
raise KernelArtifactError(f"unsafe archive path: {raw!r}")
return pure.as_posix()
def _member_bytes(archive: tarfile.TarFile, member: tarfile.TarInfo) -> bytes:
handle: BinaryIO | None = archive.extractfile(member)
if handle is None:
raise KernelArtifactError(f"archive member cannot be read: {member.name}")
return handle.read()
def _parse_internal_sums(body: bytes) -> dict[str, str]:
try:
lines = body.decode("ascii").splitlines()
except UnicodeError as exc:
raise KernelArtifactError("internal SHA256SUMS is not ASCII") from exc
result: dict[str, str] = {}
for line in lines:
if not line or " " not in line:
raise KernelArtifactError("internal SHA256SUMS contains an invalid line")
digest, name = line.split(" ", 1)
normalized = _normalized_member_name(name)
if len(digest) != 64 or normalized in result:
raise KernelArtifactError("internal SHA256SUMS contains an invalid entry")
result[normalized] = digest
return result
def verify_kernel_dependency(directory: Path) -> KernelArtifactInfo:
directory = Path(directory).resolve()
artifact, artifact_digest = _verify_outer_sum(directory, ARTIFACT_NAME)
metadata = _load_json(directory / "METADATA.json")
expected_keys = {
"schema_version",
"architecture",
"kernel_release",
"source_commit",
"source_archive_sha256",
"artifact",
"artifact_bytes",
"artifact_sha256",
"unpacked_file_bytes",
"regular_file_count",
"module_file_count",
"provenance",
}
if set(metadata) != expected_keys:
raise KernelArtifactError("kernel metadata fields are not exact")
fixed = {
"schema_version": 1,
"architecture": "aarch64",
"kernel_release": KERNEL_RELEASE,
"source_commit": SOURCE_COMMIT,
"source_archive_sha256": SOURCE_ARCHIVE_SHA256,
"artifact": ARTIFACT_NAME,
}
if any(metadata.get(key) != value for key, value in fixed.items()):
raise KernelArtifactError("kernel metadata identity is wrong")
if metadata["artifact_bytes"] != artifact.stat().st_size:
raise KernelArtifactError("kernel artifact size metadata is wrong")
if metadata["artifact_sha256"] != artifact_digest:
raise KernelArtifactError("kernel artifact digest metadata is wrong")
for key in ("unpacked_file_bytes", "regular_file_count", "module_file_count"):
if type(metadata[key]) is not int or metadata[key] <= 0:
raise KernelArtifactError(f"kernel metadata {key} must be a positive integer")
if not isinstance(metadata["provenance"], str) or not metadata["provenance"].strip():
raise KernelArtifactError("kernel metadata provenance is missing")
with artifact.open("rb") as raw:
header = raw.read(10)
if len(header) != 10 or header[:2] != b"\x1f\x8b" or header[4:8] != b"\0\0\0\0":
raise KernelArtifactError("kernel artifact gzip header is not deterministic")
try:
archive = tarfile.open(artifact, mode="r:gz")
except (tarfile.TarError, OSError, gzip.BadGzipFile) as exc:
raise KernelArtifactError("kernel artifact is not a readable tar.gz") from exc
with archive:
regular: dict[str, tarfile.TarInfo] = {}
seen: set[str] = set()
unpacked_bytes = 0
for member in archive.getmembers():
name = _normalized_member_name(member.name)
if not name:
if not member.isdir():
raise KernelArtifactError("archive root must be a directory")
continue
if name in seen:
raise KernelArtifactError(f"duplicate archive path: {name}")
seen.add(name)
if not (member.isfile() or member.isdir()):
raise KernelArtifactError(f"archive contains a link or special file: {name}")
if member.uid != 0 or member.gid != 0 or int(member.mtime) != 0:
raise KernelArtifactError(f"archive ownership or timestamp is not normalized: {name}")
if member.isfile():
regular[name] = member
unpacked_bytes += member.size
if len(regular) > MAX_REGULAR_FILES or unpacked_bytes > MAX_UNPACKED_BYTES:
raise KernelArtifactError("kernel artifact exceeds safety limits")
required = REQUIRED_BOOT_FILES | {"KERNEL_RELEASE", "SOURCE_COMMIT", "SHA256SUMS"}
missing = required - regular.keys()
if missing:
raise KernelArtifactError(f"kernel artifact is incomplete: {sorted(missing)}")
module_prefix = f"lib/modules/{KERNEL_RELEASE}/"
unexpected = {
name for name in regular if name not in required and not name.startswith(module_prefix)
}
if unexpected:
raise KernelArtifactError(f"kernel artifact contains an unowned file: {sorted(unexpected)}")
forbidden = {
f"lib/modules/{KERNEL_RELEASE}/build",
f"lib/modules/{KERNEL_RELEASE}/source",
}
if forbidden & seen:
raise KernelArtifactError("kernel artifact contains build/source module links")
if _member_bytes(archive, regular["KERNEL_RELEASE"]) != f"{KERNEL_RELEASE}\n".encode():
raise KernelArtifactError("kernel release marker is wrong")
if _member_bytes(archive, regular["SOURCE_COMMIT"]) != f"{SOURCE_COMMIT}\n".encode():
raise KernelArtifactError("kernel source commit marker is wrong")
image = _member_bytes(archive, regular["boot/Image-matrix-axp313a1"])
if f"Linux version {KERNEL_RELEASE}".encode() not in image:
raise KernelArtifactError("kernel Image does not contain the expected release")
internal = _parse_internal_sums(_member_bytes(archive, regular["SHA256SUMS"]))
verified_files = set(regular) - {"SHA256SUMS"}
if set(internal) != verified_files:
raise KernelArtifactError("internal SHA256SUMS does not cover exactly every payload file")
for name, digest in internal.items():
actual = hashlib.sha256(_member_bytes(archive, regular[name])).hexdigest()
if actual != digest:
raise KernelArtifactError(f"internal SHA-256 mismatch: {name}")
module_count = sum(1 for name in regular if name.startswith(module_prefix))
measurements = {
"unpacked_file_bytes": unpacked_bytes,
"regular_file_count": len(regular),
"module_file_count": module_count,
}
if any(metadata[key] != value for key, value in measurements.items()):
raise KernelArtifactError("kernel artifact measurements do not match metadata")
return KernelArtifactInfo(
artifact=ARTIFACT_NAME,
artifact_bytes=artifact.stat().st_size,
artifact_sha256=artifact_digest,
kernel_release=KERNEL_RELEASE,
source_commit=SOURCE_COMMIT,
**measurements,
)
def verify_source_dependency(directory: Path) -> dict[str, object]:
directory = Path(directory).resolve()
archive, digest = _verify_outer_sum(directory, SOURCE_ARCHIVE_NAME)
metadata = _load_json(directory / "METADATA.json")
expected = {
"schema_version": 1,
"source": "WalnutPi Linux 6.1.31",
"source_commit": SOURCE_COMMIT,
"archive": SOURCE_ARCHIVE_NAME,
"archive_bytes": archive.stat().st_size,
"archive_sha256": SOURCE_ARCHIVE_SHA256,
"purpose": "Offline reproducible build input for 6.1.31-matrix-axp313a1",
}
if metadata != expected or digest != SOURCE_ARCHIVE_SHA256:
raise KernelArtifactError("kernel source dependency metadata is wrong")
return expected
def main() -> int:
parser = argparse.ArgumentParser(description="Verify offline AXP313A kernel dependencies")
parser.add_argument("--kernel", type=Path, required=True)
parser.add_argument("--source", type=Path)
args = parser.parse_args()
result: dict[str, object] = {"kernel": asdict(verify_kernel_dependency(args.kernel))}
if args.source:
result["source"] = verify_source_dependency(args.source)
print(json.dumps(result, ensure_ascii=False, sort_keys=True, indent=2))
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except KernelArtifactError as exc:
print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False))
raise SystemExit(1)
@@ -0,0 +1,180 @@
from __future__ import annotations
import argparse
import hashlib
import json
import statistics
import time
import urllib.request
from io import BytesIO
from pathlib import Path
from typing import Any
from PIL import Image
class MeasurementError(RuntimeError):
pass
def fetch(url: str, *, timeout: float = 2.0) -> tuple[bytes, float]:
started = time.monotonic()
with urllib.request.urlopen(url, timeout=timeout) as response:
body = response.read()
return body, (time.monotonic() - started) * 1000.0
def rgb_digest(body: bytes) -> str:
with Image.open(BytesIO(body)) as image:
rgb = image.convert("RGB")
return hashlib.sha256(rgb.tobytes()).hexdigest()
def percentile_95(values: list[float]) -> float:
if not values:
return 0.0
ordered = sorted(values)
return ordered[max(0, min(len(ordered) - 1, int(len(ordered) * 0.95 + 0.999999) - 1))]
def driver_status(sample: dict[str, Any]) -> dict[str, Any]:
driver = (sample.get("screen") or {}).get("driver_status")
if not isinstance(driver, dict):
raise MeasurementError("status does not contain screen.driver_status")
return driver
def validate_samples(
samples: list[dict[str, Any]],
*,
performance: str,
frame_before: str,
frame_after: str,
) -> dict[str, Any]:
if len(samples) < 2:
raise MeasurementError("at least two status samples are required")
drivers = [driver_status(sample) for sample in samples]
minimum_actual = min(float(item.get("actual_refresh_rate_hz") or 0) for item in drivers)
minimum_scan = min(float(item.get("panel_scan_rate_hz") or 0) for item in drivers)
if minimum_actual < 95.0 or minimum_scan < 95.0:
raise MeasurementError("actual or panel scan refresh rate fell below 95Hz")
if any(item.get("scans_per_frame") != 1 for item in drivers):
raise MeasurementError("scans_per_frame was not 1 throughout the sample")
stalled_intervals = 0
maximum_consecutive_stalls = 0
for previous, current in zip(drivers, drivers[1:]):
frame_delta = int(current.get("completed_frames") or 0) - int(previous.get("completed_frames") or 0)
scan_delta = int(current.get("completed_scans") or 0) - int(previous.get("completed_scans") or 0)
if frame_delta < 0 or scan_delta != frame_delta:
raise MeasurementError("completed frame and scan deltas were negative or unequal")
stalled_intervals = stalled_intervals + 1 if frame_delta == 0 else 0
maximum_consecutive_stalls = max(maximum_consecutive_stalls, stalled_intervals)
if maximum_consecutive_stalls >= 2:
raise MeasurementError("completed frames stalled for two consecutive samples")
frame_delta = int(drivers[-1].get("completed_frames") or 0) - int(drivers[0].get("completed_frames") or 0)
scan_delta = int(drivers[-1].get("completed_scans") or 0) - int(drivers[0].get("completed_scans") or 0)
miss_delta = int(drivers[-1].get("deadline_misses") or 0) - int(drivers[0].get("deadline_misses") or 0)
if frame_delta <= 0 or scan_delta != frame_delta:
raise MeasurementError("completed frame/scan totals did not advance equally")
if miss_delta < 0 or miss_delta / frame_delta > 0.001:
raise MeasurementError("deadline miss rate exceeded 0.1%")
fault_fields = sorted(
key for key, value in drivers[0].items()
if "fault" in key and isinstance(value, (int, float)) and not isinstance(value, bool)
)
for field in fault_fields:
if any(float(current.get(field) or 0) != float(drivers[0].get(field) or 0) for current in drivers[1:]):
raise MeasurementError(f"{field} changed during the measurement")
for field in ("driver_error", "oe_timing_error", "safeoff_error"):
if any(item.get(field) for item in drivers):
raise MeasurementError(f"{field} was not empty")
for item in drivers:
if item.get("cpu_affinity") != 3 or item.get("cpu_affinity_active") is not True:
raise MeasurementError("CPU3 affinity was not continuously active")
if item.get("realtime_priority") != 50 or item.get("realtime_priority_active") is not True:
raise MeasurementError("SCHED_FIFO 50 was not continuously active")
if item.get("memory_locked") is not True:
raise MeasurementError("refresh memory was not continuously locked")
for sample in samples:
status = ((sample.get("system") or {}).get("performance_mode") or {})
if performance == "on":
if status.get("requested") is not True or status.get("effective") is not True:
raise MeasurementError("performance mode was not requested and effective")
if not status.get("current_governors") or any(
governor != "performance" for governor in status["current_governors"].values()
):
raise MeasurementError("not every cpufreq policy used the performance governor")
elif performance == "off":
if status.get("effective") is not False:
raise MeasurementError("performance mode remained effective while required off")
current = status.get("current_governors") or {}
restore = status.get("restore_governors") or {}
if current and current != restore:
raise MeasurementError("governors were not restored while performance mode was off")
if frame_before != frame_after:
raise MeasurementError("current RGB frame changed during thumbnail browsing")
return {
"ok": True,
"samples": len(samples),
"minimum_actual_refresh_rate_hz": minimum_actual,
"minimum_panel_scan_rate_hz": minimum_scan,
"completed_frames_delta": frame_delta,
"completed_scans_delta": scan_delta,
"deadline_misses_delta": miss_delta,
"deadline_miss_rate": miss_delta / frame_delta,
"maximum_consecutive_stalled_intervals": maximum_consecutive_stalls,
"fault_fields_checked": fault_fields,
"current_frame_rgb_sha256": frame_before,
}
def main() -> int:
parser = argparse.ArgumentParser(description="Measure real HUB75 GPIO refresh while browsing")
parser.add_argument("--base-url", default="http://127.0.0.1:8080")
parser.add_argument("--seconds", type=int, default=60)
parser.add_argument("--performance", choices=("on", "off", "ignore"), default="ignore")
parser.add_argument("--output", type=Path)
args = parser.parse_args()
if not 2 <= args.seconds <= 600:
parser.error("--seconds must be within 2..600")
frame_body, _ = fetch(f"{args.base_url}/api/display/current-frame")
frame_before = rgb_digest(frame_body)
samples: list[dict[str, Any]] = []
latencies: list[float] = []
started = time.monotonic()
for second in range(args.seconds + 1):
target = started + second
if target > time.monotonic():
time.sleep(target - time.monotonic())
body, latency = fetch(f"{args.base_url}/api/status")
samples.append(json.loads(body.decode("utf-8")))
latencies.append(latency)
frame_body, _ = fetch(f"{args.base_url}/api/display/current-frame")
result = validate_samples(
samples,
performance=args.performance,
frame_before=frame_before,
frame_after=rgb_digest(frame_body),
)
result["status_latency_p95_ms"] = percentile_95(latencies)
if result["status_latency_p95_ms"] > 500.0:
raise MeasurementError("/api/status p95 latency exceeded 500ms")
encoded = json.dumps(result, ensure_ascii=False, sort_keys=True, indent=2) + "\n"
if args.output:
args.output.write_text(encoded, encoding="utf-8", newline="\n")
print(encoded, end="")
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except MeasurementError as exc:
print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False))
raise SystemExit(1)
@@ -0,0 +1,8 @@
#!/usr/bin/env python3
from __future__ import annotations
from app.media.worker import main
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,249 @@
from __future__ import annotations
import argparse
import hashlib
import json
import os
import re
import shutil
import stat
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any
DEFAULT_SOURCE = Path("/opt/matrix-screen-controller/data")
DEFAULT_TARGET = Path("/var/lib/matrix-screen-controller")
STAGING_NAME = ".matrix-screen-controller.migrate"
ROOT_RUNTIME_FILES = {
"last_frame.png",
"startup_indicator.json",
"startup_indicator.json.tmp",
"config.json.tmp",
}
ROOT_RUNTIME_DIRECTORIES = {"runtime"}
ATOMIC_TEMP_RE = re.compile(r"^\..+\.[0-9a-f]{32}\.tmp$")
class StateRootMigrationError(RuntimeError):
"""Raised when the state root cannot be migrated without data loss."""
@dataclass(frozen=True)
class MigrationResult:
status: str
source: Path
target: Path
file_count: int
total_bytes: int
def to_dict(self) -> dict[str, Any]:
return {
"status": self.status,
"source": str(self.source),
"target": str(self.target),
"file_count": self.file_count,
"total_bytes": self.total_bytes,
}
def _is_transient(relative: Path) -> bool:
if relative.parts and relative.parts[0] in ROOT_RUNTIME_DIRECTORIES:
return True
if len(relative.parts) == 1 and relative.name in ROOT_RUNTIME_FILES:
return True
return bool(ATOMIC_TEMP_RE.fullmatch(relative.name))
def _sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def build_manifest(root: Path) -> dict[str, dict[str, Any]]:
root = Path(root)
if not root.exists():
return {}
if not root.is_dir() or root.is_symlink():
raise StateRootMigrationError(f"state root must be a real directory: {root}")
manifest: dict[str, dict[str, Any]] = {}
for path in sorted(root.rglob("*")):
relative = path.relative_to(root)
if _is_transient(relative):
continue
if path.is_symlink():
raise StateRootMigrationError(f"state root contains an unsupported symlink: {relative}")
if path.is_dir():
manifest[relative.as_posix()] = {"type": "directory"}
continue
if not path.is_file():
raise StateRootMigrationError(f"state root contains an unsupported entry: {relative}")
size = path.stat().st_size
manifest[relative.as_posix()] = {
"type": "file",
"size": size,
"sha256": _sha256(path),
}
return manifest
def _summary(manifest: dict[str, dict[str, Any]]) -> tuple[int, int]:
files = [entry for entry in manifest.values() if entry["type"] == "file"]
return len(files), sum(int(entry["size"]) for entry in files)
def _copy_ignore(source: Path):
def ignore(directory: str, names: list[str]) -> set[str]:
base = Path(directory)
ignored: set[str] = set()
for name in names:
relative = (base / name).relative_to(source)
if _is_transient(relative):
ignored.add(name)
return ignored
return ignore
def _fsync_directory(path: Path) -> None:
if os.name == "nt":
return
descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
try:
os.fsync(descriptor)
finally:
os.close(descriptor)
def _fsync_tree(root: Path) -> None:
if os.name == "nt":
return
directories = [root]
for path in root.rglob("*"):
if path.is_file():
descriptor = os.open(path, os.O_RDONLY)
try:
os.fsync(descriptor)
finally:
os.close(descriptor)
elif path.is_dir():
directories.append(path)
for directory in reversed(directories):
_fsync_directory(directory)
def _assert_staging_path(staging: Path, target: Path) -> None:
if staging.parent != target.parent or staging.name != STAGING_NAME:
raise StateRootMigrationError(f"refusing to clean unexpected staging path: {staging}")
def _remove_staging(staging: Path, target: Path) -> None:
if not staging.exists():
return
_assert_staging_path(staging, target)
if staging.is_symlink() or not staging.is_dir():
raise StateRootMigrationError(f"refusing to replace unexpected staging entry: {staging}")
shutil.rmtree(staging)
def migrate_state_root(source: Path, target: Path) -> MigrationResult:
source_input = Path(source)
target_input = Path(target)
if source_input.is_symlink() or target_input.is_symlink():
raise StateRootMigrationError("source and target state roots must not be symlinks")
source = source_input.resolve(strict=False)
target = target_input.resolve(strict=False)
if source == target:
raise StateRootMigrationError("source and target state roots must be different")
if source in target.parents or target in source.parents:
raise StateRootMigrationError("source and target state roots must not contain each other")
source_manifest = build_manifest(source)
if not source.exists():
target_manifest = build_manifest(target)
file_count, total_bytes = _summary(target_manifest)
if file_count == 0:
raise StateRootMigrationError(
"legacy source is missing and target contains no persistent files"
)
return MigrationResult("target-only", source, target, file_count, total_bytes)
source_file_count, _source_total_bytes = _summary(source_manifest)
if source_file_count == 0:
raise StateRootMigrationError("legacy source contains no persistent files")
target_manifest = build_manifest(target)
if target_manifest:
if target_manifest != source_manifest:
raise StateRootMigrationError(
"source and target both contain different state; refusing to merge or overwrite"
)
file_count, total_bytes = _summary(target_manifest)
return MigrationResult("already-prepared", source, target, file_count, total_bytes)
target_parent = target.parent
target_parent.mkdir(parents=True, exist_ok=True)
staging = target_parent / STAGING_NAME
_remove_staging(staging, target)
if target.exists():
if target.is_symlink() or not target.is_dir():
raise StateRootMigrationError(f"target state root is not a real directory: {target}")
if any(target.iterdir()):
raise StateRootMigrationError(f"target state root is not empty: {target}")
try:
shutil.copytree(
source,
staging,
copy_function=shutil.copy2,
ignore=_copy_ignore(source),
)
copied_manifest = build_manifest(staging)
if copied_manifest != source_manifest:
raise StateRootMigrationError("copied state failed SHA-256 inventory verification")
_fsync_tree(staging)
if target.exists():
target.rmdir()
os.replace(staging, target)
if os.name != "nt":
os.chmod(target, stat.S_IRWXU | stat.S_IRGRP | stat.S_IXGRP)
_fsync_directory(target_parent)
except Exception:
_remove_staging(staging, target)
raise
file_count, total_bytes = _summary(source_manifest)
return MigrationResult("migrated", source, target, file_count, total_bytes)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description=(
"Copy the legacy Matrix Screen Controller data directory to its persistent "
"Linux state root without deleting the source."
)
)
parser.add_argument("--source", type=Path, default=DEFAULT_SOURCE)
parser.add_argument("--target", type=Path, default=DEFAULT_TARGET)
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
try:
result = migrate_state_root(args.source, args.target)
except (OSError, StateRootMigrationError) as exc:
print(f"state migration failed: {exc}", file=sys.stderr)
return 1
print(json.dumps(result.to_dict(), ensure_ascii=True, sort_keys=True))
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,157 @@
from __future__ import annotations
import argparse
from datetime import datetime, timezone
import hashlib
import json
from pathlib import Path
import sys
from typing import Any
SOURCE_ROOT = Path(__file__).resolve().parents[1]
if str(SOURCE_ROOT) not in sys.path:
sys.path.insert(0, str(SOURCE_ROOT))
from app.persistence import atomic_write_many_bytes
def _read_json(path: Path) -> tuple[dict[str, Any], bytes]:
content = path.read_bytes()
value = json.loads(content.decode("utf-8"))
if not isinstance(value, dict):
raise ValueError(f"{path} must contain a JSON object")
return value, content
def _json_bytes(value: dict[str, Any]) -> bytes:
return (json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) + "\n").encode("utf-8")
def _now() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
def build_migration(
draft: dict[str, Any],
old_catalog: dict[str, Any],
old_catalog_bytes: bytes,
new_catalog: dict[str, Any],
new_catalog_bytes: bytes,
*,
excluded_copy_ids: set[str],
) -> tuple[dict[str, Any], dict[str, Any]]:
if old_catalog.get("schema_version") != 1:
raise ValueError("old catalog must use schema v1")
if new_catalog.get("schema_version") != 2:
raise ValueError("new catalog must use schema v2")
if draft.get("schema_version") != 1:
raise ValueError("draft must use schema v1")
old_digest = hashlib.sha256(old_catalog_bytes).hexdigest()
if draft.get("base_catalog_digest") != old_digest:
raise ValueError("draft does not match the supplied old catalog")
overrides = draft.get("overrides")
insertions = draft.get("insertions")
if not isinstance(overrides, dict) or not isinstance(insertions, list):
raise ValueError("draft collections are invalid")
new_items = new_catalog.get("items")
new_anchors = new_catalog.get("anchors")
if not isinstance(new_items, dict) or not isinstance(new_anchors, dict):
raise ValueError("new catalog collections are invalid")
retained_overrides: dict[str, Any] = {}
removed_overrides: list[dict[str, Any]] = []
for copy_id, override in overrides.items():
if copy_id in excluded_copy_ids:
removed_overrides.append({"copy_id": copy_id, "reason": "explicitly_excluded", "override": override})
elif copy_id not in new_items:
removed_overrides.append({"copy_id": copy_id, "reason": "not_registered_in_v2", "override": override})
else:
retained_overrides[copy_id] = override
retained_insertions: list[dict[str, Any]] = []
removed_insertions: list[dict[str, Any]] = []
for insertion in insertions:
anchor = new_anchors.get(insertion.get("anchor_id")) if isinstance(insertion, dict) else None
if not isinstance(anchor, dict) or anchor.get("scope") != insertion.get("workspace_id"):
removed_insertions.append({"reason": "anchor_not_registered_in_v2", "insertion": insertion})
else:
retained_insertions.append(insertion)
migrated_at = _now()
new_digest = hashlib.sha256(new_catalog_bytes).hexdigest()
migrated = {
"schema_version": 1,
"base_catalog_digest": new_digest,
"revision": int(draft.get("revision", -1)) + 1,
"updated_at": migrated_at,
"overrides": retained_overrides,
"insertions": retained_insertions,
}
report = {
"schema_version": 1,
"migrated_at": migrated_at,
"from_catalog_digest": old_digest,
"to_catalog_digest": new_digest,
"from_revision": draft.get("revision"),
"to_revision": migrated["revision"],
"retained_override_ids": sorted(retained_overrides),
"removed_overrides": removed_overrides,
"retained_insertion_ids": [item.get("id") for item in retained_insertions],
"removed_insertions": removed_insertions,
}
return migrated, report
def migrate_files(
draft_path: Path,
old_catalog_path: Path,
new_catalog_path: Path,
report_path: Path,
*,
excluded_copy_ids: set[str],
) -> tuple[dict[str, Any], dict[str, Any]]:
draft, _draft_bytes = _read_json(draft_path)
old_catalog, old_bytes = _read_json(old_catalog_path)
new_catalog, new_bytes = _read_json(new_catalog_path)
migrated, report = build_migration(
draft,
old_catalog,
old_bytes,
new_catalog,
new_bytes,
excluded_copy_ids=excluded_copy_ids,
)
atomic_write_many_bytes(
(
(draft_path, _json_bytes(migrated)),
(report_path, _json_bytes(report)),
)
)
return migrated, report
def main() -> None:
parser = argparse.ArgumentParser(description="Migrate a UI copy draft from catalog v1 to catalog v2")
parser.add_argument("--draft", type=Path, required=True)
parser.add_argument("--old-catalog", type=Path, required=True)
parser.add_argument("--new-catalog", type=Path, required=True)
parser.add_argument("--report", type=Path, required=True)
parser.add_argument("--exclude-copy-id", action="append", default=[])
args = parser.parse_args()
migrated, report = migrate_files(
args.draft,
args.old_catalog,
args.new_catalog,
args.report,
excluded_copy_ids=set(args.exclude_copy_id),
)
print(json.dumps({
"revision": migrated["revision"],
"retained_overrides": len(report["retained_override_ids"]),
"removed_overrides": len(report["removed_overrides"]),
}, ensure_ascii=False))
if __name__ == "__main__":
main()
@@ -0,0 +1,478 @@
#!/usr/bin/env python3
"""Durable component transaction; also runnable by old workers and at boot.
The journal is mirrored into the original and candidate data roots before any
system mutation. The worker may rename either root; one journal always survives.
The recovery executable is independent of releases that the old worker deletes.
Only stdlib imports: recovery must not depend on a candidate venv.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
from pathlib import Path
import re
import shutil
import stat
import subprocess
import time
DATA = Path('/var/lib/matrix-screen-controller')
TARGET = Path('/opt/matrix-screen-controller')
RELEASES = Path('/opt/matrix-screen-controller.releases')
RUNTIME = Path('/run/matrix-screen-controller')
HELPER = Path('/opt/matrix-screen-controller-component-recovery.py')
RECOVERY_UNIT = Path('/etc/systemd/system/matrix-screen-component-recovery.service')
ACCOUNT_POLICY = Path('/etc/sudoers.d/90-matrix-screen-controller-account')
WORK_ROOT = Path('/opt/matrix-screen-controller-ota')
RUNTIME_GUARD = Path('/run/systemd/system/matrix-screen-controller.service.d/90-matrix-ota-runtime.conf')
RUNTIME_GUARD_BODY = b'# Managed by the OTA component transaction\n[Service]\nRuntimeDirectoryPreserve=yes\n'
SERVICE = 'matrix-screen-controller.service'
WORKER = 'matrix-screen-controller-ota.service'
FRP = 'matrix-screen-frpc.service'
JOURNAL = Path('ota/component-transaction')
FILES = {
'frpc': Path('/usr/local/bin/frpc'),
'frpc-unit': Path('/etc/systemd/system/matrix-screen-frpc.service'),
'frpc-dropin': Path('/etc/systemd/system/matrix-screen-frpc.service.d/user.conf'),
'app-unit': Path('/etc/systemd/system/matrix-screen-controller.service'),
'ota-unit': Path('/etc/systemd/system/matrix-screen-controller-ota.service'),
}
def run(args: list[str], *, check=True, **kwargs):
return subprocess.run(args, check=check, capture_output=True, timeout=60, **kwargs)
def sync_directory(path: Path):
fd = os.open(path, os.O_RDONLY | os.O_DIRECTORY)
try:
os.fsync(fd)
finally:
os.close(fd)
def atomic(path: Path, body: bytes, mode=0o600):
path.parent.mkdir(parents=True, exist_ok=True)
temp = path.with_name(path.name + '.tmp')
with temp.open('wb') as handle:
os.chmod(temp, mode)
handle.write(body)
handle.flush()
os.fsync(handle.fileno())
os.replace(temp, path)
sync_directory(path.parent)
def read(path: Path):
return json.loads(path.read_text(encoding='utf-8'))
def protect_runtime():
if RUNTIME_GUARD.exists():
raise RuntimeError('OTA runtime protection path already exists; refusing to overwrite')
atomic(RUNTIME_GUARD, RUNTIME_GUARD_BODY, 0o644)
run(['systemctl', 'daemon-reload'])
value = run(['systemctl', 'show', SERVICE, '--property=RuntimeDirectoryPreserve', '--value']).stdout.strip()
if value != b'yes':
raise RuntimeError('OTA runtime directory protection did not take effect; refusing to stop service')
def release_runtime_guard():
if RUNTIME_GUARD.exists():
if RUNTIME_GUARD.read_bytes() != RUNTIME_GUARD_BODY:
raise RuntimeError('OTA runtime protection was modified; preserving it for inspection')
RUNTIME_GUARD.unlink()
try:
RUNTIME_GUARD.parent.rmdir()
except OSError:
pass
run(['systemctl', 'daemon-reload'])
def metadata(path: Path):
info = path.stat(follow_symlinks=False)
if stat.S_ISLNK(info.st_mode):
raise RuntimeError('managed component paths must not be symbolic links')
return {'mode': stat.S_IMODE(info.st_mode), 'uid': info.st_uid, 'gid': info.st_gid}
def apply_metadata(path: Path, item: dict):
os.chown(path, item['uid'], item['gid'], follow_symlinks=False)
path.chmod(item['mode'])
def permission_snapshot(root: Path):
paths = [root]
if (root / 'frp').exists():
paths += [root / 'frp', *(root / 'frp').rglob('*')]
return {str(p.relative_to(root)): metadata(p) for p in paths}
def restore_permissions(root: Path, saved: dict):
for name, item in saved.items():
path = root / name
if path.exists():
apply_metadata(path, item)
# Remove only empty directories created by the installer, never user files.
for name in ('frp/profiles', 'frp'):
if name not in saved:
try:
(root / name).rmdir()
except OSError:
pass
def mirror_permissions(candidate: Path):
for name, item in permission_snapshot(DATA).items():
path = candidate / name
if not path.exists() and (DATA / name).is_dir():
path.mkdir(parents=True)
if path.exists():
apply_metadata(path, item)
def account() -> str:
import grp
import pwd
dropin = FILES['frpc-dropin']
if dropin.is_file():
users = re.findall(r'^User=([a-zA-Z0-9_-]+)$', dropin.read_text(encoding='utf-8'), re.M)
if len(users) == 1:
try:
if pwd.getpwnam(users[0]).pw_uid != 0:
return users[0]
except KeyError:
pass
if ACCOUNT_POLICY.is_file():
match = re.fullmatch(r'([a-zA-Z0-9_-]+)\s+ALL=\(ALL:ALL\)\s+ALL\s*',
ACCOUNT_POLICY.read_text(encoding='utf-8').strip())
if match:
try:
if pwd.getpwnam(match[1]).pw_uid != 0:
return match[1]
except KeyError:
pass
raise RuntimeError('项目维护账户配置无效;尚未修改系统组件')
group = grp.getgrnam('sudo')
users = [p.pw_name for p in pwd.getpwall() if 1000 <= p.pw_uid < 65534
and (p.pw_name in group.gr_mem or p.pw_gid == group.gr_gid)
and p.pw_shell not in ('/usr/sbin/nologin', '/bin/false')]
if len(users) != 1:
raise RuntimeError('无法唯一确定非 root 维护账户,尚未修改系统组件')
return users[0]
def check_installed(source: Path, version: str):
import pwd
import grp
entries = read(source / 'UPGRADE_POLICY.json')['checkpoints']
required = {}
version_tuple = tuple(map(int, version.split('.')))
for entry in entries:
if tuple(map(int, entry['version'].split('.'))) <= version_tuple:
required.update(entry['components'])
if not required:
return
expected = required['frpc']
binary = FILES['frpc']
error = 'frp 系统组件缺失或不完整;请先修复软件安装包组件后重试'
if not binary.is_file() or hashlib.sha256(binary.read_bytes()).hexdigest() != expected['sha256']:
raise RuntimeError(error)
if metadata(binary)['mode'] != 0o755 or run([str(binary), '--version']).stdout.decode().strip() != expected['version']:
raise RuntimeError(error)
if not FILES['frpc-unit'].is_file() or FILES['frpc-unit'].read_bytes() != (source / 'systemd/matrix-screen-frpc.service').read_bytes():
raise RuntimeError(error)
user = account()
group = grp.getgrgid(pwd.getpwnam(user).pw_gid).gr_name
wanted = f'[Service]\nUser={user}\nGroup={group}\n'.encode()
if not FILES['frpc-dropin'].is_file() or FILES['frpc-dropin'].read_bytes() != wanted:
raise RuntimeError(error)
for name in ('frpc-unit', 'frpc-dropin'):
if metadata(FILES[name]) != {'mode': 0o644, 'uid': 0, 'gid': 0}:
raise RuntimeError(error)
if binary.stat().st_uid != 0 or binary.stat().st_gid != 0:
raise RuntimeError(error)
gid = pwd.getpwnam(user).pw_gid
if stat.S_IMODE(DATA.stat().st_mode) != 0o711:
raise RuntimeError(error)
for name in ('frp', 'frp/profiles'):
if not (DATA / name).is_dir():
raise RuntimeError(error)
for path in [DATA / 'frp', *(DATA / 'frp').rglob('*')]:
mode = 0o2750 if path.is_dir() else (0o600 if path == DATA / 'frp/active.env' else 0o640)
if metadata(path) != {'mode': mode, 'uid': 0, 'gid': gid}:
raise RuntimeError(error)
def begin(source: Path, candidate: Path):
request_path = RUNTIME / 'ota-request.json'
# A regular migration / local test is not authorization to touch the host.
if not request_path.is_file():
return
request = read(request_path)
job = request.get('job_id', '')
if not re.fullmatch(r'[a-zA-Z0-9_-]+', job):
raise RuntimeError('invalid component transaction id')
version = (source / 'VERSION').read_text(encoding='utf-8').strip()
expected_release = RELEASES / f'{version}-{job}'
expected_candidate = DATA.with_name(f'matrix-screen-controller.ota.{job}')
if source != expected_release or candidate != expected_candidate or version != request['target_version']:
return
if os.geteuid() != 0 or os.uname().machine != 'aarch64':
raise RuntimeError('component transaction requires AArch64 root')
from app.ota.policy import check_upgrade
from app.ota.versioning import SoftwareVersion
check_upgrade(SoftwareVersion.parse(request['current_version']), SoftwareVersion.parse(version))
if RUNTIME_GUARD.exists():
raise RuntimeError('存在未清理的 OTA 运行目录保护,请先恢复')
bundle = source / 'system-dependencies/frpc'
if not bundle.is_dir():
check_installed(source, version)
return
# Verify the pinned binary, not just a self-reported checksum file.
from app.ota.policy import required_components
expected = required_components(source, SoftwareVersion.parse(version))['frpc']
if hashlib.sha256((bundle / 'frpc').read_bytes()).hexdigest() != expected['sha256']:
raise RuntimeError('frpc payload differs from the registered dependency')
user = account()
for root in (DATA, candidate):
if (root / JOURNAL).exists():
raise RuntimeError('存在未完成组件事务,请先恢复')
journal = DATA / JOURNAL
journal.mkdir(parents=True, mode=0o700)
record = {'job_id': job, 'version': version, 'old_version': request['current_version'],
'accepted_at': request.get('accepted_at', ''),
'previous_target': os.readlink(TARGET) if TARGET.is_symlink() else None,
'permissions': permission_snapshot(DATA), 'files': {},
'active': run(['systemctl', 'is-active', '--quiet', FRP], check=False).returncode == 0,
'enabled': run(['systemctl', 'is-enabled', '--quiet', FRP], check=False).returncode == 0}
try:
for name, path in FILES.items():
record['files'][name] = metadata(path) if path.exists() else None
if path.exists():
atomic(journal / name, path.read_bytes())
atomic(journal / 'state.json', json.dumps(record).encode())
shutil.copytree(journal, candidate / JOURNAL)
# copytree itself is not durable across power loss.
for path in (candidate / JOURNAL).iterdir():
with path.open('rb') as handle:
os.fsync(handle.fileno())
sync_directory(candidate / JOURNAL)
atomic(HELPER, Path(__file__).read_bytes(), 0o700)
unit = ('[Unit]\nDescription=Recover interrupted OTA component transaction\n'
'After=local-fs.target\nBefore=matrix-screen-controller.service\n'
'[Service]\nType=oneshot\nExecStart=/usr/bin/python3 ' + str(HELPER) + ' recover\n'
'[Install]\nWantedBy=multi-user.target\n')
atomic(RECOVERY_UNIT, unit.encode(), 0o644)
run(['systemctl', 'daemon-reload'])
run(['systemctl', 'enable', RECOVERY_UNIT.name])
run(['systemd-run', '--quiet', '--collect', '--unit=matrix-screen-component-watch',
'/usr/bin/python3', str(HELPER), 'watch'])
protect_runtime()
env = os.environ.copy()
env.update(FRPC_BUNDLE=str(bundle), FRPC_RUN_USER=user)
run(['/bin/sh', str(source / 'scripts/install_frpc_system.sh')], env=env)
mirror_permissions(candidate)
check_installed(source, version)
print('FRP component transaction prepared; waiting for OTA health result')
except BaseException:
# The watcher handles subsequent worker failure. Restore immediately too,
# so a failed migration never leaves new system files while rolling back.
if (journal / 'state.json').is_file():
restore_components(journal, record)
else:
shutil.rmtree(journal)
raise
def restore_components(journal: Path, record: dict):
run(['systemctl', 'stop', FRP], check=False)
for name in ('frpc', 'frpc-unit', 'frpc-dropin'):
path = FILES[name]
item = record['files'][name]
if item is None:
path.unlink(missing_ok=True)
else:
atomic(path, (journal / name).read_bytes(), item['mode'])
apply_metadata(path, item)
restore_permissions(DATA, record['permissions'])
run(['systemctl', 'daemon-reload'])
# A previously absent unit cannot be disabled; avoid treating absence as error.
if record['files']['frpc-unit'] is not None:
run(['systemctl', 'enable' if record['enabled'] else 'disable', FRP])
else:
run(['systemctl', 'disable', FRP], check=False)
link = Path('/etc/systemd/system/multi-user.target.wants') / FRP
link.unlink(missing_ok=True)
if record['active']:
run(['systemctl', 'start', FRP])
def locate_journal():
if (DATA / JOURNAL / 'state.json').is_file():
return DATA / JOURNAL
candidates = list(DATA.parent.glob('matrix-screen-controller.rollback.*/ota/component-transaction/state.json'))
if len(candidates) == 1:
return candidates[0].parent
if candidates:
raise RuntimeError('multiple recovery journals; refusing ambiguous recovery')
return None
def committed(record: dict) -> bool:
try:
result = read(DATA / 'ota/state.json')['last_result']
return (result['status'] == 'success' and result['target_version'] == record['version']
and (TARGET / 'VERSION').read_text(encoding='utf-8').strip() == record['version']
and result['installed_at'] >= record['accepted_at'])
except (OSError, ValueError, KeyError, TypeError):
return False
def recover_application(journal: Path, record: dict):
job = record['job_id']
release = RELEASES / f"{record['version']}-{job}"
backup = DATA.with_name(f'matrix-screen-controller.rollback.{job}')
displaced = RELEASES / f"{record['old_version']}-pre-ota-{job}"
switched = TARGET.is_symlink() and TARGET.resolve() == release.resolve()
moved = not TARGET.exists() and (displaced.exists() or record['previous_target'])
if switched or moved or backup.exists():
run(['systemctl', 'stop', SERVICE], check=False)
if backup.exists():
if DATA.exists():
failed = DATA.with_name(f'matrix-screen-controller.failed.{job}')
if failed.exists():
raise RuntimeError('failed data recovery path already exists')
DATA.rename(failed)
backup.rename(DATA)
shutil.rmtree(failed)
else:
backup.rename(DATA)
journal = DATA / JOURNAL
if switched:
TARGET.unlink()
if switched or moved:
if record['previous_target']:
os.symlink(record['previous_target'], TARGET, target_is_directory=True)
elif displaced.exists():
displaced.rename(TARGET)
else:
raise RuntimeError('old application is missing; retaining recovery journal')
# Legacy rollback restores only its main unit, not its OTA unit.
for name in ('app-unit', 'ota-unit'):
if record['files'][name]:
atomic(FILES[name], (journal / name).read_bytes(), record['files'][name]['mode'])
apply_metadata(FILES[name], record['files'][name])
return journal
def finish(*, boot=False):
journal = locate_journal()
if journal is None:
return
record = read(journal / 'state.json')
success = committed(record)
if not success:
journal = recover_application(journal, record)
restore_components(journal, record)
runtime_log = RUNTIME / 'ota-worker.log'
try:
if runtime_log.is_file():
body = runtime_log.read_bytes()[-1024 * 1024:]
body = body.decode('utf-8', errors='replace').encode('utf-8')[-1024 * 1024:]
if body:
atomic(DATA / 'ota/last-failure.log', body)
except OSError:
# Diagnostic storage must never prevent recovery or its cleanup.
pass
result = {'schema_version': 1, 'last_result': {
'status': 'failed', 'target_version': record['version'],
'installed_at': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
'packaged_at': '', 'release_notes': '', 'error': 'OTA 未完成,组件与应用已恢复'}}
# Preserve a more detailed failure result produced by the worker.
try:
existing = read(DATA / 'ota/state.json')['last_result']
except (OSError, ValueError, KeyError):
existing = {}
if existing.get('status') != 'failed' or existing.get('target_version') != record['version']:
atomic(DATA / 'ota/state.json', json.dumps(result).encode())
status_path = RUNTIME / 'ota-status.json'
try:
status = read(status_path)
except (OSError, ValueError):
status = {}
if status.get('job', {}).get('id') == record['job_id'] and status.get('job', {}).get('phase') != 'failed':
status['active'] = False
status['job'].update(phase='failed', percent=0, message='更新中断,已恢复原版本',
error='组件事务已恢复', finished_at=result['last_result']['installed_at'])
atomic(status_path, json.dumps(status).encode())
run(['systemctl', 'daemon-reload'])
if not boot:
run(['systemctl', 'start', '--no-block', SERVICE])
job = record['job_id']
# Finish cleanup even if the old worker died after persisting its success.
release = RELEASES / f"{record['version']}-{job}"
candidate = DATA.with_name(f'matrix-screen-controller.ota.{job}')
backup = DATA.with_name(f'matrix-screen-controller.rollback.{job}')
if success:
old_release = RELEASES / f"{record['old_version']}-pre-ota-{job}"
if record['previous_target']:
previous = Path(record['previous_target'])
previous = previous if previous.is_absolute() else TARGET.parent / previous
if previous.parent == RELEASES and previous != release:
old_release = previous
if old_release.exists() and old_release != TARGET.resolve():
shutil.rmtree(old_release)
if backup.exists():
shutil.rmtree(backup)
elif release.exists() and release != TARGET.resolve():
shutil.rmtree(release)
if candidate.exists():
shutil.rmtree(candidate)
work = WORK_ROOT / f'work.{job}'
if work.exists():
shutil.rmtree(work)
request_path = RUNTIME / 'ota-request.json'
try:
request = read(request_path)
except (OSError, ValueError):
request = {}
if request.get('job_id') == job:
package = Path(request.get('package_path', ''))
if package.parent == WORK_ROOT / 'uploads' and package.name == job + '.ota':
package.unlink(missing_ok=True)
request_path.unlink(missing_ok=True)
release_runtime_guard()
for root in (DATA, DATA.with_name(f'matrix-screen-controller.ota.{job}'),
DATA.with_name(f'matrix-screen-controller.rollback.{job}')):
if (root / JOURNAL).exists():
shutil.rmtree(root / JOURNAL)
# Installer payloads are transaction inputs, not persistent application data.
if success and (release / 'system-dependencies').is_dir():
shutil.rmtree(release / 'system-dependencies')
run(['systemctl', 'disable', RECOVERY_UNIT.name])
RECOVERY_UNIT.unlink(missing_ok=True)
run(['systemctl', 'daemon-reload'])
HELPER.unlink(missing_ok=True)
def main():
parser = argparse.ArgumentParser()
parser.add_argument('mode', choices=['watch', 'recover'])
args = parser.parse_args()
if os.geteuid() != 0:
raise RuntimeError('root required')
if args.mode == 'watch':
while True:
state = run(['systemctl', 'show', WORKER, '--property=ActiveState', '--value']).stdout.strip()
if state not in (b'active', b'activating', b'deactivating'):
break
time.sleep(0.5)
finish(boot=args.mode == 'recover')
if __name__ == '__main__':
main()
@@ -0,0 +1,482 @@
#!/usr/bin/env python3
"""Privileged one-shot worker for a previously validated browser OTA package."""
from __future__ import annotations
import hashlib
import json
import os
from pathlib import Path
import shutil
import signal
import subprocess
import sys
import time
import traceback
from typing import Any
from app.ota.diagnostics import DiagnosticLog, clear_failure_log, persist_failure_log, sha256_file
from app.ota.package import extract_payload, inspect_package
from app.ota.state import read_json, utc_now, write_json, write_last_result
from app.ota.versioning import SoftwareVersion
TARGET = Path("/opt/matrix-screen-controller")
RELEASES = Path("/opt/matrix-screen-controller.releases")
DATA_ROOT = Path("/var/lib/matrix-screen-controller")
RUNTIME_ROOT = Path("/run/matrix-screen-controller")
OTA_WORK_ROOT = Path("/opt/matrix-screen-controller-ota")
UNIT_PATH = Path("/etc/systemd/system/matrix-screen-controller.service")
SERVICE = "matrix-screen-controller.service"
REQUEST_PATH = RUNTIME_ROOT / "ota-request.json"
class UpdateFailed(RuntimeError):
pass
def snapshot(root: Path, *, ignored: set[str] | None = None) -> dict[str, tuple[int, str]]:
records: dict[str, tuple[int, str]] = {}
ignored = ignored or set()
if not root.exists():
return records
for path in sorted((item for item in root.rglob("*") if item.is_file()), key=lambda item: item.as_posix()):
relative = path.relative_to(root).as_posix()
if relative in ignored:
continue
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
records[relative] = (path.stat().st_size, digest.hexdigest())
return records
class Transaction:
def __init__(self, request: dict[str, Any]) -> None:
self.request = request
self.job_id = checked_token(request.get("job_id"), "job_id")
self.target_version = SoftwareVersion.parse(str(request.get("target_version")))
self.current_version = SoftwareVersion.parse(str(request.get("current_version")))
if self.target_version <= self.current_version:
raise UpdateFailed("target version is not newer than the installed version")
self.package_path = Path(str(request.get("package_path")))
self.status_path = Path(str(request.get("status_path")))
if self.status_path != RUNTIME_ROOT / "ota-status.json":
raise UpdateFailed("unexpected OTA status path")
self.work_root = OTA_WORK_ROOT / f"work.{self.job_id}"
self.extract_root = self.work_root / "extracted"
self.release = RELEASES / f"{self.target_version}-{self.job_id}"
self.data_candidate = DATA_ROOT.with_name(f"matrix-screen-controller.ota.{self.job_id}")
self.data_backup = DATA_ROOT.with_name(f"matrix-screen-controller.rollback.{self.job_id}")
self.unit_backup = self.work_root / "matrix-screen-controller.service.old"
self.previous_target: Path | None = None
self.previous_directory: Path | None = None
self.program_swapped = False
self.data_swapped = False
self.visual: subprocess.Popen[bytes] | None = None
self.diagnostics = DiagnosticLog(RUNTIME_ROOT / "ota-worker.log")
self.diagnostics.reset()
self.diagnostics.section("更新任务")
self.diagnostics.write(
f"job_id={self.job_id} current_version={self.current_version} "
f"target_version={self.target_version} packaged_at={request.get('packaged_at') or ''}"
)
self.job = {
"id": self.job_id,
"target_version": str(self.target_version),
"packaged_at": str(request.get("packaged_at") or ""),
"phase": "preparing",
"percent": 8,
"message": "正在准备更新",
"started_at": str(request.get("accepted_at") or utc_now()),
"finished_at": None,
"error": None,
}
def update(self, phase: str, percent: int, message: str) -> None:
self.diagnostics.write(f"phase={phase} percent={percent} message={message}")
self.job.update(phase=phase, percent=max(0, min(100, percent)), message=message)
write_json(self.status_path, {"schema_version": 1, "active": True, "job": self.job})
def run(
self,
command: list[str],
*,
cwd: Path | None = None,
env: dict[str, str] | None = None,
check: bool = True,
failure_label: str | None = None,
log_output: bool = True,
) -> subprocess.CompletedProcess[bytes]:
return self.diagnostics.run(
command,
cwd=cwd,
env=env,
check=check,
failure_label=failure_label,
log_output=log_output,
)
def prepare(self) -> None:
if any(path.exists() for path in (self.work_root, self.release, self.data_candidate, self.data_backup)):
raise UpdateFailed("OTA transaction paths already exist")
self.work_root.mkdir(parents=True, mode=0o700)
test_root = self.work_root / "test-root"
test_tmp = self.work_root / "test-tmp"
test_root.mkdir(parents=True, mode=0o700)
test_tmp.mkdir(parents=True, mode=0o700)
self.diagnostics.host_summary(test_root=test_root)
self.diagnostics.section("更新包校验")
self.diagnostics.write(f"package={self.package_path.name} sha256={sha256_file(self.package_path)}")
package = inspect_package(self.package_path, current_version=self.current_version)
self.diagnostics.write(
f"package_valid=true target_version={package.target_version} expanded_bytes={package.expanded_bytes}"
)
if package.target_version != self.target_version:
raise UpdateFailed("request and package versions do not match")
stat = shutil.disk_usage("/opt")
required = max(package.expanded_bytes * 4, 512 * 1024 * 1024)
if stat.free < required:
raise UpdateFailed("not enough free space to stage and validate the OTA release")
self.update("extracting", 12, "正在解压全量更新包")
extract_payload(package, self.extract_root)
software = self.extract_root / "software"
wheelhouse = self.extract_root / "wheelhouse"
frpc_bundle = software / "system-dependencies/frpc"
if SoftwareVersion.parse((software / "VERSION").read_text(encoding="utf-8")) != self.target_version:
raise UpdateFailed("extracted software VERSION does not match the package")
self.update("dependencies", 22, "正在校验离线依赖并创建运行环境")
self.run(["sha256sum", "-c", "SHA256SUMS"], cwd=wheelhouse, failure_label="离线 wheel 摘要校验")
if frpc_bundle.is_dir():
self.run(["sha256sum", "-c", "SHA256SUMS"], cwd=frpc_bundle, failure_label="frpc 系统依赖摘要校验")
shutil.move(str(software), self.release)
self.run(["python3", "--version"], failure_label="系统 Python 版本检查")
self.run(["python3", "-m", "venv", str(self.release / ".venv")], failure_label="创建虚拟环境")
self.run([
str(self.release / ".venv/bin/pip"),
"install",
"--no-index",
"--disable-pip-version-check",
"--find-links",
str(wheelhouse),
"-r",
str(self.release / "requirements-dev.txt"),
], failure_label="安装离线依赖")
self.run([str(self.release / ".venv/bin/python"), "-m", "pip", "--version"], failure_label="pip 版本检查")
self.run([str(self.release / ".venv/bin/python"), "-m", "pytest", "--version"], failure_label="pytest 版本检查")
self.update("building", 42, "正在编译并测试屏幕驱动")
self.run(
["make", "-C", str(self.release / "app/display/native"), "clean", "all", "test"],
failure_label="原生驱动编译与测试",
)
self.run(
[str(self.release / ".venv/bin/python"), "-m", "compileall", "-q", str(self.release / "app")],
failure_label="Python 静态编译",
)
self.update("testing", 55, "正在运行新版本自动化测试")
test_env = os.environ.copy()
test_env.update(
MATRIX_DRIVER="mock",
MATRIX_TEST_ROOT=str(test_root),
MATRIX_DATA_DIR=str(test_root / "data"),
MATRIX_RUNTIME_DIR=str(test_root / "runtime"),
MATRIX_SOURCE_ONLY_UPDATE_TESTS="1",
TMPDIR=str(test_tmp),
TEMP=str(test_tmp),
TMP=str(test_tmp),
)
self.run([
str(self.release / ".venv/bin/python"), "-m", "pytest",
"-p", "no:cacheprovider", "-q", f"--basetemp={self.work_root / 'pytest'}",
"--tb=short", "--disable-warnings",
], cwd=self.release, env=test_env, failure_label="新版本 pytest")
self.run(
[str(self.release / ".venv/bin/python"), str(self.release / "scripts/dedicated_host.py"), "check"],
failure_label="专用主机检查",
)
self.update("migrating", 68, "正在迁移用户数据副本")
shutil.copytree(DATA_ROOT, self.data_candidate, symlinks=True)
runtime_candidate = self.work_root / "migration-runtime"
migration_env = os.environ.copy()
migration_env["PYTHONPATH"] = str(self.release)
self.run([
str(self.release / ".venv/bin/python"),
"-m",
"scripts.prepare_data_root",
"--data-root",
str(self.data_candidate),
"--runtime-root",
str(runtime_candidate),
], cwd=self.release, env=migration_env, failure_label="用户数据副本迁移")
def switch(self) -> None:
self.update("switching", 78, "正在切换到新版本")
shutil.copy2(UNIT_PATH, self.unit_backup)
self.run(["systemctl", "stop", SERVICE], failure_label="停止旧服务")
self.start_visual()
RELEASES.mkdir(parents=True, exist_ok=True)
if TARGET.is_symlink():
self.previous_target = Path(os.readlink(TARGET))
TARGET.unlink()
elif TARGET.is_dir():
self.previous_directory = RELEASES / f"{self.current_version}-pre-ota-{self.job_id}"
TARGET.rename(self.previous_directory)
else:
raise UpdateFailed("production target is neither a release symlink nor a directory")
os.symlink(self.release, TARGET, target_is_directory=True)
self.program_swapped = True
DATA_ROOT.rename(self.data_backup)
self.data_candidate.rename(DATA_ROOT)
self.data_swapped = True
shutil.copy2(self.release / "systemd/matrix-screen-controller.service", UNIT_PATH)
shutil.copy2(self.release / "systemd/matrix-screen-controller-ota.service", Path("/etc/systemd/system/matrix-screen-controller-ota.service"))
# prepare_data_root owns the component transaction, including for legacy
# workers. Its independent guardian commits after our final health result.
self.run(["systemctl", "daemon-reload"], failure_label="重载 systemd unit")
self.stop_visual()
self.run(["systemctl", "start", SERVICE], failure_label="启动新服务")
def verify(self) -> None:
self.update("verifying", 90, "正在验证新版本和真实屏幕驱动")
deadline = time.monotonic() + 45
last_error = "service did not respond"
attempt = 0
while time.monotonic() < deadline:
attempt += 1
try:
response = self.run(
[
"curl", "--fail", "--silent", "--max-time", "5",
"http://127.0.0.1:8080/api/status",
],
check=False,
failure_label=f"健康检查第 {attempt} 次",
log_output=False,
)
if response.returncode != 0:
raise UpdateFailed(f"health endpoint returned exit code {response.returncode}")
status = json.loads(response.stdout.decode("utf-8", errors="strict"))
screen = status.get("screen", {})
driver = screen.get("driver_status") or {}
if status.get("service", {}).get("software_version") != str(self.target_version):
raise UpdateFailed("new service reports the wrong software version")
if screen.get("driver") != "walnutpi-h618-hub75":
raise UpdateFailed("new service did not start the production HUB75 driver")
if screen.get("hardware_mapping") != "walnutpi-pi-bank-pwm-oe-v2":
raise UpdateFailed("new service reports the wrong hardware mapping")
if driver.get("oe_timing_backend") != "h618-pwm4" or driver.get("oe_pulse_faults") != 0:
raise UpdateFailed("new service did not pass the PWM4 OE health check")
self.diagnostics.write(
"health_summary="
f"version:{status.get('service', {}).get('software_version')} "
f"driver:{screen.get('driver')} mapping:{screen.get('hardware_mapping')} "
f"oe_backend:{driver.get('oe_timing_backend')} oe_pulse_faults:{driver.get('oe_pulse_faults')}"
)
self.diagnostics.write(f"health_check_passed=true attempts={attempt}")
return
except (UpdateFailed, OSError, UnicodeError, json.JSONDecodeError) as exc:
last_error = str(exc)
self.diagnostics.write(f"health_check_attempt={attempt} failed={exc}")
time.sleep(1)
raise UpdateFailed(f"new release health check timed out: {last_error}")
def succeed(self) -> None:
self.diagnostics.section("更新成功")
self.diagnostics.write("health_check=passed rollback_required=false")
result = {
"status": "success",
"target_version": str(self.target_version),
"packaged_at": str(self.request.get("packaged_at") or ""),
"installed_at": utc_now(),
"release_notes": str(self.request.get("release_notes") or ""),
"error": None,
}
clear_failure_log(DATA_ROOT)
write_last_result(DATA_ROOT, result)
self.job.update(
phase="complete",
percent=100,
message="更新完成",
finished_at=result["installed_at"],
error=None,
)
write_json(self.status_path, {"schema_version": 1, "active": False, "job": self.job})
self.package_path.unlink(missing_ok=True)
self.request_path().unlink(missing_ok=True)
shutil.rmtree(self.data_backup, ignore_errors=True)
if self.previous_directory is not None:
shutil.rmtree(self.previous_directory, ignore_errors=True)
if self.previous_target is not None:
previous_release = self.previous_target if self.previous_target.is_absolute() else TARGET.parent / self.previous_target
if previous_release.parent == RELEASES:
shutil.rmtree(previous_release, ignore_errors=True)
shutil.rmtree(self.work_root, ignore_errors=True)
prune_empty(self.package_path.parent)
self.diagnostics.path.unlink(missing_ok=True)
def rollback(self, error: BaseException) -> None:
self.diagnostics.section("失败与回滚")
self.diagnostics.write(f"failure={error.__class__.__name__}: {error}")
self.diagnostics.write(
"traceback_short:\n" + "".join(traceback.format_exception(error, limit=8)).rstrip()
)
rollback_errors: list[str] = []
def attempt(label: str, action) -> None:
try:
action()
self.diagnostics.write(f"rollback_step={label} result=ok")
except BaseException as rollback_error:
detail = f"{label}: {rollback_error.__class__.__name__}: {rollback_error}"
rollback_errors.append(detail)
self.diagnostics.write(f"rollback_step={label} result=failed detail={detail}")
attempt("停止独立显示进程", self.stop_visual)
if self.program_swapped or self.data_swapped:
attempt(
"停止待回滚服务",
lambda: self.run(["systemctl", "stop", SERVICE], check=False, failure_label="回滚停止服务"),
)
if self.data_swapped:
failed_data = DATA_ROOT.with_name(f"matrix-screen-controller.failed.{self.job_id}")
if DATA_ROOT.exists():
attempt("移开失败数据", lambda: DATA_ROOT.rename(failed_data))
if self.data_backup.exists():
attempt("恢复原数据", lambda: self.data_backup.rename(DATA_ROOT))
attempt("清理失败数据", lambda: shutil.rmtree(failed_data, ignore_errors=True))
if self.program_swapped:
if TARGET.is_symlink():
attempt("移除失败版本链接", TARGET.unlink)
if self.previous_directory is not None and self.previous_directory.exists():
attempt("恢复原程序目录", lambda: self.previous_directory.rename(TARGET))
elif self.previous_target is not None:
attempt(
"恢复原程序链接",
lambda: os.symlink(self.previous_target, TARGET, target_is_directory=True),
)
if self.unit_backup.exists():
attempt("恢复原 systemd unit", lambda: shutil.copy2(self.unit_backup, UNIT_PATH))
attempt(
"回滚后重载 systemd",
lambda: self.run(["systemctl", "daemon-reload"], check=False, failure_label="回滚重载 systemd"),
)
if self.program_swapped or self.data_swapped:
attempt(
"启动原服务",
lambda: self.run(["systemctl", "start", SERVICE], check=False, failure_label="回滚启动原服务"),
)
attempt("清理失败程序候选", lambda: shutil.rmtree(self.release, ignore_errors=True))
attempt("清理数据候选", lambda: shutil.rmtree(self.data_candidate, ignore_errors=True))
recovery = "回滚完成" if not rollback_errors else "回滚存在错误:" + ";".join(rollback_errors)
self.diagnostics.write(f"final_recovery_state={recovery}")
brief_error = str(error).splitlines()[0][:1000] or error.__class__.__name__
result = {
"status": "failed",
"target_version": str(self.target_version),
"packaged_at": str(self.request.get("packaged_at") or ""),
"installed_at": utc_now(),
"release_notes": str(self.request.get("release_notes") or ""),
"error": brief_error,
}
try:
self.diagnostics.persist(DATA_ROOT)
self.diagnostics.write("persistent_failure_log=written")
except OSError as persist_error:
self.diagnostics.write(
f"persistent_failure_log=failed detail={persist_error.__class__.__name__}: {persist_error}"
)
try:
write_last_result(DATA_ROOT, result)
except OSError:
pass
self.job.update(
phase="failed",
percent=0,
message="更新失败,已恢复原版本",
finished_at=result["installed_at"],
error=brief_error,
)
write_json(self.status_path, {"schema_version": 1, "active": False, "job": self.job})
self.package_path.unlink(missing_ok=True)
self.request_path().unlink(missing_ok=True)
shutil.rmtree(self.work_root, ignore_errors=True)
self.diagnostics.path.unlink(missing_ok=True)
def start_visual(self) -> None:
self.diagnostics.section("启动独立更新显示")
python = TARGET / ".venv/bin/python"
self.visual = subprocess.Popen([
str(python), "-m", "scripts.hub75_visual_hold",
"--mode", "ota",
"--brightness", "40",
"--progress-file", str(self.status_path),
"--orientation", str(int(self.request.get("orientation", 0))),
])
time.sleep(1)
if self.visual.poll() is not None:
raise UpdateFailed("independent OTA display process failed to start")
self.diagnostics.write("ota_visual_process=running")
def stop_visual(self) -> None:
if self.visual is None:
return
if self.visual.poll() is None:
self.visual.send_signal(signal.SIGTERM)
try:
self.visual.wait(timeout=5)
except subprocess.TimeoutExpired:
self.visual.kill()
self.visual.wait(timeout=2)
self.visual = None
self.diagnostics.write("ota_visual_process=stopped")
def request_path(self) -> Path:
return Path(str(self.request.get("request_path") or REQUEST_PATH))
def checked_token(value: Any, name: str) -> str:
candidate = str(value or "")
if not candidate or any(character not in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-" for character in candidate):
raise UpdateFailed(f"invalid {name}")
return candidate
def prune_empty(path: Path) -> None:
try:
path.rmdir()
except OSError:
pass
def main() -> int:
if os.geteuid() != 0 or os.uname().machine != "aarch64":
print("OTA worker must run as root on the WalnutPi AArch64 host", file=sys.stderr)
return 2
request = read_json(REQUEST_PATH)
if request is None or request.get("schema_version") != 1:
print("OTA request is missing or invalid", file=sys.stderr)
return 2
transaction: Transaction | None = None
try:
transaction = Transaction(request)
transaction.prepare()
transaction.switch()
transaction.verify()
transaction.succeed()
return 0
except BaseException as exc:
if transaction is not None:
transaction.rollback(exc)
print(f"OTA update failed: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,41 @@
#!/usr/bin/env python3
"""Run every registered persistent-data migration against an isolated data root."""
from __future__ import annotations
import argparse
import os
from pathlib import Path
from app.animations.store import AnimationStore
from app.config.store import ConfigStore
from app.fonts.store import FontCatalog
from app.library_order import LibraryOrderStore
from app.network.store import WifiConfigStore
from app.templates.store import TemplateStore
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--data-root", required=True)
parser.add_argument("--runtime-root", required=True)
args = parser.parse_args()
data_root = Path(args.data_root).resolve()
runtime_root = Path(args.runtime_root).resolve()
os.environ["MATRIX_DATA_DIR"] = str(data_root)
os.environ["MATRIX_RUNTIME_DIR"] = str(runtime_root)
config = ConfigStore()
fonts = FontCatalog(config.data_dir)
TemplateStore(config.data_dir, font_resolver=fonts.resolve)
AnimationStore(config.data_dir, font_resolver=fonts.resolve)
LibraryOrderStore(config.data_dir)
WifiConfigStore(config.data_dir, config.runtime_dir, boot_id="ota-preflight")
from scripts.ota_components import begin
begin(Path(__file__).resolve().parents[1], data_root)
print(f"Persistent data prepared successfully: schema={config.path.name}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,174 @@
"""Promote an exact, real-card-validated image candidate without rebuilding it."""
from __future__ import annotations
from datetime import datetime, timezone
import hashlib
import json
import os
from pathlib import Path
import shutil
import subprocess
import tempfile
import uuid
from app.ota.versioning import SoftwareVersion, read_software_version
from scripts.build_sd_image import build_bundle, sha256_file
from scripts.fat16_image import Fat16Image
from scripts.image_config import read_config_file
REPORT_FIELDS = {
"schema_version", "artifact_type", "image_sha256", "software_version", "status",
"firstboot_completed", "automatic_reboot_passed", "default_wifi_connected",
"ssh_password_login_passed", "sudo_password_passed", "root_login_rejected",
"networkmanager_passed", "controller_service_passed", "kernel_health_passed",
"h618_mapping_passed", "web_ui_passed", "plaintext_config_removed",
"machine_identity_regenerated", "second_reboot_passed", "validated_at",
}
PASS_FIELDS = REPORT_FIELDS - {
"schema_version", "artifact_type", "image_sha256", "software_version", "status", "validated_at"
}
def _validate_report(path: Path, digest: str, version: SoftwareVersion) -> dict:
report = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(report, dict) or set(report) != REPORT_FIELDS:
raise ValueError("image validation report fields are invalid")
try:
validated_at = datetime.fromisoformat(report.get("validated_at", ""))
except (TypeError, ValueError) as exc:
raise ValueError("image validation timestamp is invalid") from exc
if (
report["schema_version"] != 1
or report["artifact_type"] != "image"
or report["image_sha256"] != digest
or report["software_version"] != str(version)
or report["status"] != "success"
or validated_at.tzinfo is None
or any(report[name] is not True for name in PASS_FIELDS)
):
raise ValueError("candidate lacks matching successful real-card validation")
return report
def promote(source: Path, candidate: Path, validation: Path, notes: str) -> Path:
from scripts.export_release import (
_atomic_bytes, _atomic_json, _copy_source, _history, _image_readme, next_patch,
)
source = Path(source).resolve()
project = source.parent
candidate = Path(candidate).resolve()
validation = Path(validation).resolve()
lock = project / ".release-export.lock"
descriptor = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
os.close(descriptor)
archive: Path | None = None
staged: Path | None = None
final: Path | None = None
published = False
history_path = project / "发布记录.json"
history_original = history_path.read_bytes()
version_original = (source / "VERSION").read_bytes()
try:
if not candidate.is_dir():
raise ValueError("validated image candidate directory is missing")
manifest = json.loads((candidate / "manifest.json").read_text(encoding="utf-8"))
version = SoftwareVersion.parse(manifest.get("software_version", ""))
current = read_software_version(source)
if version != next_patch(current):
raise ValueError("validated image candidate must be the next patch version")
artifact_name = f"matrix-screen-controller-{version}.img"
if {path.name for path in candidate.iterdir()} != {
"README.md", "manifest.json", artifact_name, f"{artifact_name}.sha256",
}:
raise ValueError("image candidate directory contents are not exact")
artifact = candidate / artifact_name
digest = sha256_file(artifact)
if (
manifest.get("artifact_type") != "image"
or manifest.get("artifact") != artifact_name
or manifest.get("image_sha256") != digest
or manifest.get("image_bytes") != artifact.stat().st_size
or manifest.get("notes") != notes.strip()
or (candidate / f"{artifact_name}.sha256").read_bytes()
!= f"{digest} {artifact_name}\n".encode("ascii")
):
raise ValueError("image candidate metadata does not match its artifact")
with Fat16Image(artifact) as image:
config_bytes = image.read_file("MSCCFG.BIN")
config, _generation, _slot = read_config_file(config_bytes)
if config["software_version"] != str(version):
raise ValueError("image candidate configuration version is invalid")
if manifest.get("config_sha256") != hashlib.sha256(config_bytes).hexdigest():
raise ValueError("image candidate configuration digest is invalid")
if (candidate / "README.md").read_text(encoding="utf-8") != _image_readme(version, manifest, notes, config):
raise ValueError("image candidate README does not match its embedded defaults")
_validate_report(validation, digest, version)
dependency_root = project / "发布更新相关" / "其他依赖"
kernel = dependency_root / "aarch64-kernel" / "6.1.31-matrix-axp313a1"
with tempfile.TemporaryDirectory(prefix="matrix-image-promotion-") as temporary_text:
temporary = Path(temporary_text)
staged_source = temporary / "核桃派软件源代码"
_copy_source(source, staged_source, version)
expected_bundle = temporary / "MSCBOOT.TGZ"
build_bundle(
staged_source,
dependency_root / "aarch64-py311",
dependency_root / "debian12-aarch64",
expected_bundle,
dependency_root / "frp" / "0.71.0" / "linux-arm64",
)
subprocess.run(
[
"bash", str(staged_source / "scripts" / "verify_image_bootstrap.sh"),
str(artifact), str(staged_source / "systemd" / "matrix-image-firstboot.service"),
str(staged_source / "systemd" / "10-walnutpi-screen-hardening.conf"),
str(kernel), str(expected_bundle),
],
check=True,
)
history = _history(history_path)
if any(record.get("version") == str(version) for record in history["releases"]):
raise ValueError("validated image version is already registered")
final = project / "发布更新相关" / "导出包" / str(version)
if final.exists():
raise ValueError("formal image release directory already exists")
stamp = datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
archive = project / "各种归档" / f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_IMAGE{version}实卡验收_{uuid.uuid4().hex[:6]}"
archive.mkdir(parents=True)
shutil.copy2(validation, archive / "实机验收.json")
staged = final.parent / f".{version}.{uuid.uuid4().hex}.publishing"
shutil.copytree(candidate, staged)
if sha256_file(staged / artifact_name) != digest:
raise ValueError("candidate copy checksum mismatch")
history["releases"].append({
"version": str(version),
"artifact_type": "image",
"created_at": manifest["created_at"],
"notes": notes.strip(),
"artifact_path": f"发布更新相关/导出包/{version}/{artifact_name}",
"artifact_sha256": digest,
"validation_path": f"{archive.relative_to(project).as_posix()}/实机验收.json",
"validated_at": stamp,
})
os.replace(staged, final)
staged = None
published = True
_atomic_json(history_path, history)
_atomic_bytes(source / "VERSION", f"{version}\n".encode("utf-8"))
return final
except BaseException:
if staged is not None:
shutil.rmtree(staged, ignore_errors=True)
if published and final is not None:
shutil.rmtree(final, ignore_errors=True)
_atomic_bytes(history_path, history_original)
_atomic_bytes(source / "VERSION", version_original)
if archive is not None:
shutil.rmtree(archive, ignore_errors=True)
raise
finally:
lock.unlink(missing_ok=True)
@@ -0,0 +1,250 @@
from __future__ import annotations
import argparse
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
import uuid
try:
import pwd
except ImportError: # pragma: no cover - allows Windows unit tests to import this module
pwd = None # type: ignore[assignment]
from scripts.image_config import read_config_path
SOURCE_ROOT = Path(__file__).resolve().parents[1]
SSH_POLICY_SOURCE = SOURCE_ROOT / "systemd" / "10-walnutpi-screen-hardening.conf"
SSH_POLICY_TARGET = Path("/etc/ssh/sshd_config.d/10-walnutpi-screen-hardening.conf")
SSH_RUNTIME_DIRECTORY = Path("/run/sshd")
SUDOERS_TARGET = Path("/etc/sudoers.d/90-matrix-screen-controller-account")
SUDOERS_MAIN = Path("/etc/sudoers")
HOSTS_PATH = Path("/etc/hosts")
SSH_EXPECTED = {
"permitrootlogin": "no",
"passwordauthentication": "yes",
"kbdinteractiveauthentication": "no",
"permitemptypasswords": "no",
}
def run(args: list[str], *, input_text: str | None = None) -> None:
subprocess.run(args, input=input_text, text=True, check=True)
def _nm_escape(value: str) -> str:
return value.replace("\\", "\\\\").replace(";", "\\;")
def _rooted(root: Path, path: Path) -> Path:
return root / path.relative_to("/")
def _remove_temporary(path: Path) -> None:
if os.name == "nt" and path.exists():
path.chmod(0o600)
path.unlink(missing_ok=True)
def _atomic_write(path: Path, data: str, mode: int) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
try:
temporary.write_text(data, encoding="utf-8", newline="\n")
temporary.chmod(mode)
os.replace(temporary, path)
finally:
_remove_temporary(temporary)
def _sanitize_vendor_sudoers(data: str) -> str:
forbidden = (
re.compile(r"^\s*Defaults\s+rootpw\s*(?:#.*)?$"),
re.compile(r"^\s*pi\s+ALL\s*=\s*\(ALL\)\s+NOPASSWD\s*:\s*ALL\s*(?:#.*)?$"),
)
lines = [line for line in data.splitlines() if not any(pattern.fullmatch(line) for pattern in forbidden)]
return "\n".join(lines) + "\n"
def _install_sudoers(path: Path, main_path: Path, username: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
main_temporary = main_path.with_name(f".{main_path.name}.{uuid.uuid4().hex}.tmp")
try:
temporary.write_text(f"{username} ALL=(ALL:ALL) ALL\n", encoding="utf-8", newline="\n")
temporary.chmod(0o440)
main_temporary.write_text(
_sanitize_vendor_sudoers(main_path.read_text(encoding="utf-8")),
encoding="utf-8",
newline="\n",
)
main_temporary.chmod(0o440)
run(["visudo", "-cf", str(temporary)])
run(["visudo", "-cf", str(main_temporary)])
os.replace(main_temporary, main_path)
os.replace(temporary, path)
run(["visudo", "-c"])
finally:
_remove_temporary(temporary)
_remove_temporary(main_temporary)
def _configure_hostname_mapping(path: Path, hostname: str) -> None:
lines = path.read_text(encoding="utf-8").splitlines()
replacement = f"127.0.1.1\t{hostname}"
replaced = False
for index, line in enumerate(lines):
fields = line.split()
if fields and fields[0] == "127.0.1.1":
lines[index] = replacement
replaced = True
break
if not replaced:
lines.append(replacement)
_atomic_write(path, "\n".join(lines) + "\n", 0o644)
def configure_network(config: dict) -> None:
wifi = config["wifi"]
ipv4 = config["ipv4"]
connection_uuid = str(uuid.uuid4())
lines = [
"[connection]",
"id=matrix-screen",
f"uuid={connection_uuid}",
"type=wifi",
"interface-name=wlan0",
"autoconnect=true",
"",
"[wifi]",
"mode=infrastructure",
f"ssid={_nm_escape(wifi['ssid'])}",
"",
"[wifi-security]",
"key-mgmt=wpa-psk",
f"psk={_nm_escape(wifi['password'])}",
"",
"[ipv4]",
]
if ipv4["mode"] == "dhcp":
lines.extend(("method=auto", ""))
else:
dns = ";".join(ipv4["dns"]) + ";"
lines.extend(
(
f"address1={ipv4['address']}/{ipv4['prefix']},{ipv4['gateway']}",
f"dns={dns}",
"method=manual",
"",
)
)
lines.extend(("[ipv6]", "method=disabled", ""))
root = Path("/etc/NetworkManager/system-connections")
root.mkdir(parents=True, exist_ok=True)
for candidate in root.glob("*.nmconnection"):
candidate.unlink()
target = root / "matrix-screen.nmconnection"
target.write_text("\n".join(lines), encoding="utf-8", newline="\n")
target.chmod(0o600)
def configure_account(config: dict, *, root: Path = Path("/")) -> None:
if pwd is None:
raise RuntimeError("account provisioning requires the POSIX pwd module")
account = config["account"]
username = account["username"]
try:
pwd.getpwnam(username)
except KeyError:
pi = pwd.getpwnam("pi")
groups = subprocess.check_output(["id", "-nG", "pi"], text=True).split()
supplementary = ",".join(group for group in groups if group not in {"pi", username})
args = ["useradd", "--create-home", "--shell", "/bin/bash"]
if supplementary:
args.extend(("--groups", supplementary))
args.append(username)
run(args)
home = _rooted(root, Path(f"/home/{username}"))
if Path(pi.pw_dir).is_dir() and not any(home.iterdir()):
for source in Path(pi.pw_dir).iterdir():
if source.name not in {".ssh", ".bash_history"}:
destination = home / source.name
if source.is_dir():
shutil.copytree(source, destination, symlinks=True)
elif source.is_file():
shutil.copy2(source, destination)
run(["chown", "-R", f"{username}:{username}", str(home)])
run(["chpasswd"], input_text=f"{username}:{account['password']}\n")
run(["usermod", "--append", "--groups", "sudo", username])
sudoers = _rooted(root, SUDOERS_TARGET)
_install_sudoers(sudoers, _rooted(root, SUDOERS_MAIN), username)
if username != "pi":
run(["passwd", "--lock", "pi"])
def reset_identity(*, root: Path = Path("/")) -> None:
_configure_hostname_mapping(_rooted(root, HOSTS_PATH), "matrix-screen")
run(["hostnamectl", "set-hostname", "matrix-screen"])
run(["systemctl", "stop", "ssh.service"])
for key in _rooted(root, Path("/etc/ssh")).glob("ssh_host_*"):
if key.is_file():
key.unlink()
_rooted(root, Path("/etc/machine-id")).write_text("", encoding="ascii")
dbus = _rooted(root, Path("/var/lib/dbus/machine-id"))
if dbus.exists() or dbus.is_symlink():
dbus.unlink()
run(["systemd-machine-id-setup"])
run(["ssh-keygen", "-A"])
def configure_ssh(*, root: Path = Path("/")) -> None:
policy = SSH_POLICY_SOURCE.read_text(encoding="utf-8")
target = _rooted(root, SSH_POLICY_TARGET)
_atomic_write(target, policy, 0o644)
runtime = _rooted(root, SSH_RUNTIME_DIRECTORY)
runtime.mkdir(parents=True, exist_ok=True)
runtime.chmod(0o755)
run(["/usr/sbin/sshd", "-t"])
effective = subprocess.check_output(["/usr/sbin/sshd", "-T"], text=True)
observed = {}
for line in effective.splitlines():
key, separator, value = line.partition(" ")
if separator and key in SSH_EXPECTED:
observed[key] = value.strip()
if observed != SSH_EXPECTED:
raise RuntimeError(f"effective SSH policy is invalid: {observed!r}")
run(["systemctl", "unmask", "ssh.service"])
run(["systemctl", "enable", "ssh.service"])
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("command", choices=("network", "account", "identity", "ssh", "print-public"))
parser.add_argument("--config", type=Path, required=True)
args = parser.parse_args()
config = read_config_path(args.config)
if args.command == "network":
configure_network(config)
elif args.command == "account":
configure_account(config)
elif args.command == "identity":
reset_identity()
elif args.command == "ssh":
configure_ssh()
else:
public = {
"software_version": config["software_version"],
"username": config["account"]["username"],
"ssid": config["wifi"]["ssid"],
"ipv4": config["ipv4"],
}
print(json.dumps(public, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,41 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
from pathlib import Path
import sys
SOURCE_ROOT = Path(__file__).resolve().parents[1]
if str(SOURCE_ROOT) not in sys.path:
sys.path.insert(0, str(SOURCE_ROOT))
from scripts.fat16_image import Fat16Image
from scripts.image_config import PRODUCT_ID, read_config_file
def reject_in_place_refresh(image_path: Path) -> None:
image_path = Path(image_path).resolve()
with Fat16Image(image_path) as image:
config, _, _ = read_config_file(image.read_file("MSCCFG.BIN"))
metadata = json.loads(image.read_file("MSCMETA.JSN").decode("utf-8"))
if config.get("product") != PRODUCT_ID or metadata.get("product") != PRODUCT_ID:
raise ValueError("image product does not match this project")
raise ValueError(
"in-place image payload refresh is retired; rebuild from the official baseline with "
"export_release.py image --repair-current"
)
def main() -> int:
parser = argparse.ArgumentParser(
description="Reject unsafe in-place image refreshes and direct callers to the transactional exporter"
)
parser.add_argument("--image", type=Path, required=True)
args = parser.parse_args()
reject_in_place_refresh(args.image)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,69 @@
from __future__ import annotations
import argparse
from pathlib import Path
RELEASE_TAG = "matrix-axp313a1"
MARKER = "matrix-kernel-good"
class BootScriptError(RuntimeError):
pass
def render(source: str) -> str:
if "matrix_kernel_candidate=1" in source:
raise BootScriptError("boot script is already candidate-managed")
anchor = (
'if test "${docker_optimizations}" = "on"; then setenv bootargs '
'"${bootargs} cgroup_enable=memory swapaccount=1"; fi\n'
)
if source.count(anchor) != 1:
raise BootScriptError("bootargs anchor is missing or ambiguous")
candidate = f'''
# Matrix AXP313A candidate: consume the marker before boot so a failed reboot falls back.
setenv matrix_kernel_file "Image"
setenv matrix_dtb_suffix ""
if test -e ${{devtype}} ${{devnum}} ${{prefix}}{MARKER}; then
if test -e ${{devtype}} ${{devnum}} ${{prefix}}Image-{RELEASE_TAG}; then
fatrm ${{devtype}} ${{devnum}} ${{prefix}}{MARKER}
setenv matrix_kernel_file "Image-{RELEASE_TAG}"
setenv matrix_dtb_suffix "-{RELEASE_TAG}"
setenv bootargs "${{bootargs}} matrix_kernel_candidate=1 panic=10"
echo "Booting one-shot Matrix AXP313A candidate"
fi
fi
'''
replacements = {
"${prefix}${fdtfile_emmc}.dtb": "${prefix}${fdtfile_emmc}${matrix_dtb_suffix}.dtb",
"${prefix}${fdtfile}.dtb": "${prefix}${fdtfile}${matrix_dtb_suffix}.dtb",
"${prefix}Image": "${prefix}${matrix_kernel_file}",
}
expected = {
"${prefix}${fdtfile_emmc}.dtb": 1,
"${prefix}${fdtfile}.dtb": 2,
"${prefix}Image": 1,
}
for old, new in replacements.items():
if source.count(old) != expected[old]:
raise BootScriptError(f"boot path {old!r} is missing or ambiguous")
source = source.replace(old, new)
rendered = source.replace(anchor, anchor + candidate, 1)
return rendered
def main() -> int:
parser = argparse.ArgumentParser(description="Render a one-shot dual-kernel WalnutPi boot.cmd")
parser.add_argument("source", type=Path)
parser.add_argument("destination", type=Path)
args = parser.parse_args()
source = args.source.read_text(encoding="utf-8")
rendered = render(source)
args.destination.write_text(rendered, encoding="utf-8", newline="\n")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,102 @@
"""Promote an explicitly authorized, device-validated OTA without rebuilding it."""
from __future__ import annotations
from datetime import datetime, timezone
import json
import os
from pathlib import Path
import shutil
import tempfile
import uuid
from app.ota.package import inspect_package, extract_payload, _source_file_allowed
from app.ota.policy import read_policy, required_components, release_metadata, package_format
from app.ota.versioning import read_software_version
from app.ota.diagnostics import sha256_file
def promote(source: Path, candidate: Path, validation: Path, notes: str) -> Path:
from scripts.export_release import _history, _commit_repair
project = source.parent
lock = project / '.release-export.lock'
fd = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
os.close(fd)
try:
version = read_software_version(source)
current = project / '发布更新相关' / 'OTA数据包' / str(version)
name = f'matrix-screen-controller-{version}.ota'
history_path = project / '发布记录.json'
history = _history(history_path)
matches = [(i, r) for i, r in enumerate(history['releases']) if r['version'] == str(version)]
if len(matches) != 1 or matches[0][1]['artifact_type'] != 'ota':
raise ValueError('current version must have exactly one OTA release record')
index, previous = matches[0]
original_sha = sha256_file(current / name)
manifest = json.loads((current / 'manifest.json').read_text(encoding='utf-8'))
if (previous['artifact_sha256'] != original_sha or manifest['artifact_sha256'] != original_sha
or (current / (name + '.sha256')).read_text(encoding='ascii') != f'{original_sha} {name}\n'):
raise ValueError('original OTA release metadata does not match its artifact')
info = inspect_package(candidate)
digest = sha256_file(candidate)
report = json.loads(validation.read_text(encoding='utf-8'))
if (info.target_version != version or report.get('package_sha256') != digest
or report.get('installed_version') != str(version) or report.get('status') != 'success'
or report.get('runtime_lifecycle_passed') is not True
or report.get('user_data_preserved') is not True
or report.get('frp_state_preserved') is not True
or report.get('transaction_cleanup_passed') is not True):
raise ValueError('candidate lacks matching successful real-device validation')
if digest == original_sha:
raise ValueError('repair candidate is identical to the current artifact')
with tempfile.TemporaryDirectory(prefix='matrix-ota-repair-check-') as text:
unpacked = Path(text) / 'unpacked'
extract_payload(info, unpacked)
software = unpacked / 'software'
expected = {p.relative_to(source).as_posix() for p in source.rglob('*')
if p.is_file() and _source_file_allowed(p, source)}
actual = {p.relative_to(software).as_posix() for p in software.rglob('*')
if p.is_file() and _source_file_allowed(p, software)}
if actual != expected or any((software / n).read_bytes() != (source / n).read_bytes() for n in expected):
raise ValueError('validated candidate differs from current source; validate a new candidate')
for component, requirement in required_components(source, version).items():
if version.patch == 0 and sha256_file(software / 'system-dependencies' / component / component) != requirement['sha256']:
raise ValueError('candidate system dependency differs from policy')
stamp = datetime.now(timezone.utc).astimezone().isoformat(timespec='seconds')
archive = project / '各种归档' / f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_OTA{version}修复前_{uuid.uuid4().hex[:6]}"
archive.mkdir(parents=True)
shutil.copytree(current, archive / '原安装包')
shutil.copy2(history_path, archive / '发布记录_修复前.json')
shutil.copy2(validation, archive / '实机验收.json')
staged = current.parent / f'.{version}.{uuid.uuid4().hex}.repairing'
staged.mkdir()
try:
shutil.copy2(candidate, staged / name)
if sha256_file(staged / name) != digest:
raise ValueError('candidate copy checksum mismatch')
metadata = {**manifest, **release_metadata(version), 'format_version': package_format(version),
'created_at': info.created_at, 'notes': notes, 'artifact_bytes': candidate.stat().st_size,
'artifact_sha256': digest, 'repaired_at': stamp, 'replaces_sha256': original_sha}
(staged / 'manifest.json').write_text(json.dumps(metadata, ensure_ascii=False, indent=2)+'\n', encoding='utf-8')
(staged / (name+'.sha256')).write_text(f'{digest} {name}\n', encoding='ascii', newline='\n')
(staged / 'README.md').write_text(
f'# OTA {version} 软件安装包(修复版)\n\n{notes}\n\n'
f'- SHA-256:`{digest}`\n- 修复时间:`{stamp}`\n'
'- 通过系统设置上传 `.ota`,无需解压。1.0.x 必须先安装本节点,再安装后续补丁。\n'
'- 已通过真实 systemd 停启测试及测试设备 OTA;正式包与实机验收包字节一致。\n'
'- 已安装 frp 时保留配置和启停状态,完整组件跳过,缺失或损坏时离线修复。\n'
'- 本包修复停止旧服务时运行目录被删除的问题;仍为 1.1.0,不是 1.1.1。\n'
f'- 原失败包与旧记录:`{archive.relative_to(project).as_posix()}`。\n', encoding='utf-8')
repair = {'at': stamp, 'reason': notes, 'previous_sha256': original_sha,
'archive_path': archive.relative_to(project).as_posix()}
history['releases'][index] = {**previous, 'created_at': info.created_at, 'notes': notes,
'artifact_sha256': digest, 'component_checkpoints': read_policy(source)['checkpoints'],
'repairs': [*previous.get('repairs', []), repair]}
leftover = _commit_repair(current, staged, history_path, history)
if leftover:
print(f'obsolete temporary backup requires cleanup: {leftover}')
except BaseException:
shutil.rmtree(staged, ignore_errors=True)
raise
return current
finally:
lock.unlink(missing_ok=True)
@@ -0,0 +1,72 @@
#!/usr/bin/env python3
"""Explicit opt-in real-systemd regression; no production service is stopped."""
from pathlib import Path
import importlib.util
import json
import os
import shutil
import subprocess
import uuid
def main():
if os.geteuid() != 0 or not Path('/run/systemd/system').is_dir():
raise RuntimeError('this explicit integration check requires root and real systemd')
spec = importlib.util.spec_from_file_location('runtime_guard', Path(__file__).with_name('ota_components.py'))
component = importlib.util.module_from_spec(spec)
spec.loader.exec_module(component)
token = 'matrix-runtime-test-' + uuid.uuid4().hex
service = token + '.service'
unit = Path('/run/systemd/system') / service
directory = Path('/run') / token
dropin = unit.with_name(service + '.d') / '90-matrix-ota-runtime.conf'
component.SERVICE = service
component.RUNTIME_GUARD = dropin
def run(*args, check=True):
return subprocess.run(['systemctl', *args], check=check, capture_output=True)
def write_unit(command='/bin/true'):
unit.write_text('[Unit]\nDescription=Isolated OTA RuntimeDirectory regression\n'
'[Service]\nType=oneshot\nRemainAfterExit=yes\n'
f'ExecStart={command}\nRuntimeDirectory={token}\nRuntimeDirectoryMode=0750\n'
'RuntimeDirectoryPreserve=restart\n', encoding='utf-8')
def markers():
for name in ('ota-request.json', 'ota-status.json', 'ota-worker.log'):
(directory / name).write_bytes(b'unchanged')
def assert_markers():
assert all((directory / n).read_bytes() == b'unchanged'
for n in ('ota-request.json', 'ota-status.json', 'ota-worker.log'))
try:
write_unit()
run('daemon-reload');run('start', service);markers()
run('stop', service)
assert not directory.exists(), 'negative control did not reproduce stop cleanup'
print('PASS: real systemd Preserve=restart deletes runtime directory on explicit stop', flush=True)
run('start', service);markers()
component.protect_runtime()
run('restart', service);assert_markers()
run('stop', service);assert_markers()
write_unit('/bin/false');run('daemon-reload')
assert run('start', service, check=False).returncode != 0
assert_markers()
component.release_runtime_guard()
assert not dropin.exists()
assert run('show', service, '--property=RuntimeDirectoryPreserve', '--value').stdout.strip() == b'restart'
print('PASS: real stop/restart/start-failure preserve OTA request/status/log; temporary protection cleaned', flush=True)
print(json.dumps({'runtime_lifecycle_passed': True}))
finally:
run('stop', service, check=False)
unit.unlink(missing_ok=True)
if dropin.exists():
dropin.unlink()
try:
dropin.parent.rmdir()
except OSError:
pass
run('daemon-reload');run('reset-failed', service, check=False)
assert directory.parent == Path('/run') and directory.name.startswith('matrix-runtime-test-')
if directory.exists():
shutil.rmtree(directory)
if __name__ == '__main__':
main()
@@ -0,0 +1,262 @@
#!/bin/sh
set -eu
if [ "$(id -u)" -ne 0 ]; then
echo "update_walnutpi.sh must run as root" >&2
exit 1
fi
if [ "$(uname -m)" != "aarch64" ]; then
echo "this update is only for the WalnutPi AArch64 host" >&2
exit 1
fi
SOURCE_ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
PROJECT_ROOT=$(CDPATH= cd -- "$SOURCE_ROOT/.." && pwd)
WHEELHOUSE=${WHEELHOUSE:-$PROJECT_ROOT/发布更新相关/其他依赖/aarch64-py311}
FRPC_BUNDLE=${FRPC_BUNDLE:-$PROJECT_ROOT/发布更新相关/其他依赖/frp/0.71.0/linux-arm64}
MANIFEST=$WHEELHOUSE/SHA256SUMS
TARGET=/opt/matrix-screen-controller
DATA_ROOT=/var/lib/matrix-screen-controller
UNIT=/etc/systemd/system/matrix-screen-controller.service
OTA_UNIT=/etc/systemd/system/matrix-screen-controller-ota.service
SERVICE=matrix-screen-controller.service
SOFTWARE_VERSION=$(tr -d '\r\n' < "$SOURCE_ROOT/VERSION")
RELEASE_ID=${RELEASE_ID:-$SOFTWARE_VERSION-$(date -u +%Y%m%dT%H%M%SZ)}
RELEASES=/opt/matrix-screen-controller.releases
case "$RELEASE_ID" in
*[!A-Za-z0-9._-]*|'')
echo "RELEASE_ID may contain only letters, digits, dot, underscore, and dash" >&2
exit 1
;;
esac
STAGING=/opt/matrix-screen-controller.stage.$RELEASE_ID
BACKUP=/opt/matrix-screen-controller.rollback.$RELEASE_ID
NEW_RELEASE=$RELEASES/$RELEASE_ID
DATA_CANDIDATE=/var/lib/matrix-screen-controller.update.$RELEASE_ID
DATA_BACKUP=/var/lib/matrix-screen-controller.rollback.$RELEASE_ID
MIGRATION_RUNTIME=/run/matrix-screen-controller-update.$RELEASE_ID.migration
STATUS_FILE=/run/matrix-screen-controller-update.$RELEASE_ID.status
TEST_DATA=/run/matrix-screen-controller-update.$RELEASE_ID.test-data
TEST_RUNTIME=/run/matrix-screen-controller-update.$RELEASE_ID.test-runtime
FRPC_BACKUP=/run/matrix-screen-controller-update.$RELEASE_ID.frpc-backup
SWAPPED=0
DATA_BACKED_UP=0
SUCCESS=0
SERVICE_STOPPED=0
FRPC_CHANGED=0
FRPC_WAS_ACTIVE=0
FRPC_WAS_ENABLED=0
PREVIOUS_LINK=
cleanup_paths() {
for path in "$STAGING" "$DATA_CANDIDATE" "$MIGRATION_RUNTIME" "$STATUS_FILE" "$TEST_DATA" "$TEST_RUNTIME" "$FRPC_BACKUP"; do
case "$path" in
/opt/matrix-screen-controller.stage.$RELEASE_ID|/var/lib/matrix-screen-controller.update.$RELEASE_ID|/run/matrix-screen-controller-update.$RELEASE_ID.migration|/run/matrix-screen-controller-update.$RELEASE_ID.status|/run/matrix-screen-controller-update.$RELEASE_ID.test-data|/run/matrix-screen-controller-update.$RELEASE_ID.test-runtime|/run/matrix-screen-controller-update.$RELEASE_ID.frpc-backup)
if [ -d "$path" ]; then rm -rf -- "$path"; else rm -f -- "$path"; fi
;;
esac
done
}
rollback() {
if [ "$SUCCESS" -eq 1 ]; then
return
fi
if [ "$SWAPPED" -eq 1 ] || [ "$DATA_BACKED_UP" -eq 1 ]; then
echo "new release failed; restoring previous program and persistent data" >&2
fi
if [ "$SERVICE_STOPPED" -eq 1 ] || [ "$SWAPPED" -eq 1 ] || [ "$DATA_BACKED_UP" -eq 1 ]; then
systemctl stop "$SERVICE" || true
fi
if [ "$DATA_BACKED_UP" -eq 1 ]; then
FAILED_DATA=/var/lib/matrix-screen-controller.failed.$RELEASE_ID
if [ -e "$DATA_ROOT" ] && [ ! -e "$FAILED_DATA" ]; then mv -- "$DATA_ROOT" "$FAILED_DATA"; fi
if [ -d "$DATA_BACKUP" ]; then mv -- "$DATA_BACKUP" "$DATA_ROOT"; fi
case "$FAILED_DATA" in
/var/lib/matrix-screen-controller.failed.$RELEASE_ID)
if [ -d "$FAILED_DATA" ]; then rm -rf -- "$FAILED_DATA"; fi
;;
esac
fi
if [ "$FRPC_CHANGED" -eq 1 ]; then
systemctl stop matrix-screen-frpc.service 2>/dev/null || true
if [ -f "$FRPC_BACKUP/frpc" ]; then install -m 0755 "$FRPC_BACKUP/frpc" /usr/local/bin/frpc; else rm -f -- /usr/local/bin/frpc; fi
if [ -f "$FRPC_BACKUP/unit" ]; then install -m 0644 "$FRPC_BACKUP/unit" /etc/systemd/system/matrix-screen-frpc.service; else rm -f -- /etc/systemd/system/matrix-screen-frpc.service; fi
if [ -f "$FRPC_BACKUP/dropin" ]; then
install -d -m 0755 /etc/systemd/system/matrix-screen-frpc.service.d
install -m 0644 "$FRPC_BACKUP/dropin" /etc/systemd/system/matrix-screen-frpc.service.d/user.conf
else
rm -f -- /etc/systemd/system/matrix-screen-frpc.service.d/user.conf
fi
systemctl daemon-reload
if [ "$FRPC_WAS_ENABLED" -eq 1 ]; then systemctl enable matrix-screen-frpc.service >/dev/null; else systemctl disable matrix-screen-frpc.service >/dev/null 2>&1 || true; fi
if [ "$FRPC_WAS_ACTIVE" -eq 1 ]; then systemctl start matrix-screen-frpc.service || true; fi
fi
if [ "$SWAPPED" -eq 1 ]; then
if [ -L "$TARGET" ]; then
rm -f -- "$TARGET"
elif [ -d "$TARGET" ]; then
FAILED=/opt/matrix-screen-controller.failed.$RELEASE_ID
if [ ! -e "$FAILED" ]; then mv -- "$TARGET" "$FAILED"; fi
fi
if [ -d "$BACKUP" ]; then
mv -- "$BACKUP" "$TARGET"
elif [ -n "$PREVIOUS_LINK" ]; then
ln -s -- "$PREVIOUS_LINK" "$TARGET"
fi
rm -rf -- "$NEW_RELEASE"
install -m 0644 "$TARGET/systemd/matrix-screen-controller.service" "$UNIT"
install -m 0644 "$TARGET/systemd/matrix-screen-controller-ota.service" "$OTA_UNIT"
systemctl daemon-reload
fi
if [ "$SERVICE_STOPPED" -eq 1 ] || [ "$SWAPPED" -eq 1 ] || [ "$DATA_BACKED_UP" -eq 1 ]; then
systemctl start "$SERVICE" || true
fi
}
finish() {
code=$?
trap - EXIT HUP INT TERM
rollback
cleanup_paths
exit "$code"
}
trap finish EXIT HUP INT TERM
if [ ! -f "$MANIFEST" ]; then
echo "offline wheel manifest missing: $MANIFEST" >&2
exit 1
fi
if [ ! -d "$TARGET" ]; then
echo "existing production target missing: $TARGET" >&2
exit 1
fi
if [ ! -d "$DATA_ROOT" ]; then
echo "existing persistent data root missing: $DATA_ROOT" >&2
exit 1
fi
if [ -e "$STAGING" ] || [ -e "$BACKUP" ] || [ -e "$NEW_RELEASE" ] || [ -e "$DATA_CANDIDATE" ] || [ -e "$DATA_BACKUP" ]; then
echo "release staging or rollback path already exists for $RELEASE_ID" >&2
exit 1
fi
(cd "$WHEELHOUSE" && sha256sum -c SHA256SUMS)
mkdir -m 0750 -- "$STAGING"
cp -a "$SOURCE_ROOT/." "$STAGING/"
python3 -m venv "$STAGING/.venv"
"$STAGING/.venv/bin/pip" install \
--no-index \
--find-links "$WHEELHOUSE" \
-r "$STAGING/requirements-dev.txt"
make -C "$STAGING/app/display/native" clean all test
"$STAGING/.venv/bin/python" -m compileall -q "$STAGING/app"
mkdir -p -- "$TEST_DATA" "$TEST_RUNTIME/tmp"
(cd "$STAGING" && \
MATRIX_DRIVER=mock \
MATRIX_TEST_ROOT="$TEST_DATA" \
MATRIX_DATA_DIR="$TEST_DATA" \
MATRIX_RUNTIME_DIR="$TEST_RUNTIME" \
MATRIX_SOURCE_ONLY_UPDATE_TESTS=1 \
TMPDIR="$TEST_RUNTIME/tmp" \
TEMP="$TEST_RUNTIME/tmp" \
TMP="$TEST_RUNTIME/tmp" \
"$STAGING/.venv/bin/python" -m pytest -p no:cacheprovider -q \
--basetemp="$TEST_RUNTIME/pytest" --tb=short --disable-warnings)
"$STAGING/.venv/bin/python" "$STAGING/scripts/dedicated_host.py" check
systemctl stop "$SERVICE"
SERVICE_STOPPED=1
cp -a -- "$DATA_ROOT" "$DATA_CANDIDATE"
PYTHONPATH="$STAGING" "$STAGING/.venv/bin/python" -m scripts.prepare_data_root \
--data-root "$DATA_CANDIDATE" \
--runtime-root "$MIGRATION_RUNTIME"
mkdir -p -- "$RELEASES"
if [ -L "$TARGET" ]; then
PREVIOUS_LINK=$(readlink "$TARGET")
rm -f -- "$TARGET"
SWAPPED=1
else
mv -- "$TARGET" "$BACKUP"
SWAPPED=1
fi
mv -- "$STAGING" "$NEW_RELEASE"
ln -s -- "$NEW_RELEASE" "$TARGET"
mv -- "$DATA_ROOT" "$DATA_BACKUP"
DATA_BACKED_UP=1
mv -- "$DATA_CANDIDATE" "$DATA_ROOT"
install -m 0644 "$TARGET/systemd/matrix-screen-controller.service" "$UNIT"
install -m 0644 "$TARGET/systemd/matrix-screen-controller-ota.service" "$OTA_UNIT"
install -d -m 0700 "$FRPC_BACKUP"
systemctl is-active --quiet matrix-screen-frpc.service 2>/dev/null && FRPC_WAS_ACTIVE=1 || true
systemctl is-enabled --quiet matrix-screen-frpc.service 2>/dev/null && FRPC_WAS_ENABLED=1 || true
if [ -f /usr/local/bin/frpc ]; then cp -p -- /usr/local/bin/frpc "$FRPC_BACKUP/frpc"; fi
if [ -f /etc/systemd/system/matrix-screen-frpc.service ]; then cp -p -- /etc/systemd/system/matrix-screen-frpc.service "$FRPC_BACKUP/unit"; fi
if [ -f /etc/systemd/system/matrix-screen-frpc.service.d/user.conf ]; then cp -p -- /etc/systemd/system/matrix-screen-frpc.service.d/user.conf "$FRPC_BACKUP/dropin"; fi
FRPC_CHANGED=1
FRPC_BUNDLE="$FRPC_BUNDLE" /bin/sh "$TARGET/scripts/install_frpc_system.sh"
systemctl daemon-reload
systemctl start "$SERVICE"
check_health() {
attempt=0
while [ "$attempt" -lt 30 ]; do
if curl --fail --silent http://127.0.0.1:8080/api/status >"$STATUS_FILE"; then
if "$TARGET/.venv/bin/python" - "$STATUS_FILE" <<'PY'
import json
from pathlib import Path
import sys
status = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
screen = status.get("screen", {})
driver = screen.get("driver_status") or {}
expected_version = Path("/opt/matrix-screen-controller/VERSION").read_text(encoding="utf-8").strip()
assert status.get("service", {}).get("software_version") == expected_version
assert screen.get("driver") == "walnutpi-h618-hub75"
assert screen.get("hardware_mapping") == "walnutpi-pi-bank-pwm-oe-v2"
assert driver.get("oe_timing_backend") == "h618-pwm4"
assert driver.get("oe_pulse_faults") == 0
PY
then
return 0
fi
fi
attempt=$((attempt + 1))
sleep 1
done
return 1
}
if ! check_health; then
echo "new release did not pass the production health check" >&2
exit 1
fi
systemctl restart "$SERVICE"
if ! check_health; then
echo "new release did not pass the restart health check" >&2
exit 1
fi
SUCCESS=1
SWAPPED=0
DATA_BACKED_UP=0
SERVICE_STOPPED=0
FRPC_CHANGED=0
case "$BACKUP" in
/opt/matrix-screen-controller.rollback.$RELEASE_ID)
rm -rf -- "$BACKUP"
;;
esac
case "$DATA_BACKUP" in
/var/lib/matrix-screen-controller.rollback.$RELEASE_ID)
rm -rf -- "$DATA_BACKUP"
;;
esac
case "$PREVIOUS_LINK" in
/opt/matrix-screen-controller.releases/*)
if [ "$PREVIOUS_LINK" != "$NEW_RELEASE" ]; then rm -rf -- "$PREVIOUS_LINK"; fi
;;
esac
systemctl --no-pager --full status "$SERVICE"
echo "WalnutPi Matrix Screen Controller updated successfully: $RELEASE_ID"
@@ -0,0 +1,124 @@
#!/bin/bash
set -Eeuo pipefail
if [ "$(id -u)" -ne 0 ]; then
printf '%s\n' 'verify_image_bootstrap.sh must run as root.' >&2
exit 1
fi
if [ "$#" -ne 5 ]; then
printf '%s\n' 'usage: verify_image_bootstrap.sh IMAGE SERVICE_UNIT SSH_CONFIG KERNEL_DEPENDENCY APP_BUNDLE' >&2
exit 2
fi
IMAGE=$(readlink -f -- "$1")
UNIT=$(readlink -f -- "$2")
SSH_CONFIG=$(readlink -f -- "$3")
KERNEL_DEPENDENCY=$(readlink -f -- "$4")
APP_BUNDLE=$(readlink -f -- "$5")
SCRIPT_ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
[ -f "$IMAGE" ]
[ -f "$UNIT" ]
[ -f "$SSH_CONFIG" ]
[ -d "$KERNEL_DEPENDENCY" ]
[ -f "$APP_BUNDLE" ]
[ -f "$KERNEL_DEPENDENCY/SHA256SUMS" ]
[ -f "$KERNEL_DEPENDENCY/METADATA.json" ]
LOOP=
MOUNT_DIR=$(mktemp -d /var/tmp/matrix-image-verify.XXXXXX)
BOOT_MOUNT=$(mktemp -d /var/tmp/matrix-image-boot-verify.XXXXXX)
cleanup() {
if mountpoint -q "$BOOT_MOUNT"; then
umount "$BOOT_MOUNT"
fi
if mountpoint -q "$MOUNT_DIR"; then
umount "$MOUNT_DIR"
fi
if [ -n "$LOOP" ]; then
losetup -d "$LOOP"
fi
rmdir "$MOUNT_DIR" 2>/dev/null || true
rmdir "$BOOT_MOUNT" 2>/dev/null || true
}
trap cleanup EXIT
LOOP=$(losetup --find --show --partscan --read-only "$IMAGE")
ROOT_PARTITION="${LOOP}p2"
BOOT_PARTITION="${LOOP}p1"
for _attempt in $(seq 1 20); do
[ -b "$ROOT_PARTITION" ] && break
sleep 0.25
done
[ -b "$ROOT_PARTITION" ]
[ -b "$BOOT_PARTITION" ]
mount -o ro,noload "$ROOT_PARTITION" "$MOUNT_DIR"
mount -o ro "$BOOT_PARTITION" "$BOOT_MOUNT"
INSTALLED="$MOUNT_DIR/etc/systemd/system/matrix-image-firstboot.service"
ENABLED="$MOUNT_DIR/etc/systemd/system/multi-user.target.wants/matrix-image-firstboot.service"
SSH_ENABLED="$MOUNT_DIR/etc/systemd/system/multi-user.target.wants/ssh.service"
SSH_INSTALLED="$MOUNT_DIR/etc/ssh/sshd_config.d/10-walnutpi-screen-hardening.conf"
cmp -s "$UNIT" "$INSTALLED"
cmp -s "$SSH_CONFIG" "$SSH_INSTALLED"
[ -L "$ENABLED" ]
[ "$(readlink "$ENABLED")" = '../matrix-image-firstboot.service' ]
grep -Fxq 'ExecStart=/boot/MSCINIT' "$INSTALLED"
grep -Eq '^Before=.*ssh\.service.*boot_script\.service' "$INSTALLED"
[ -x "$MOUNT_DIR/usr/sbin/sshd" ]
[ -x "$MOUNT_DIR/usr/bin/sudo" ]
[ -x "$MOUNT_DIR/usr/sbin/visudo" ]
[ -f "$MOUNT_DIR/lib/systemd/system/ssh.service" ]
[ -L "$SSH_ENABLED" ]
[ "$(basename "$(readlink "$SSH_ENABLED")")" = 'ssh.service' ]
[ ! -L "$MOUNT_DIR/etc/systemd/system/ssh.service" ] || [ "$(readlink "$MOUNT_DIR/etc/systemd/system/ssh.service")" != '/dev/null' ]
[ ! -s "$MOUNT_DIR/etc/machine-id" ]
[ "$(readlink "$MOUNT_DIR/var/lib/dbus/machine-id")" = '/etc/machine-id' ]
if find "$MOUNT_DIR/etc/ssh" -maxdepth 1 -name 'ssh_host_*' -print -quit 2>/dev/null | grep -q .; then
printf '%s\n' 'export still contains an SSH host key' >&2
exit 1
fi
if find "$MOUNT_DIR/etc/NetworkManager/system-connections" -mindepth 1 -maxdepth 1 -type f -print -quit 2>/dev/null | grep -q .; then
printf '%s\n' 'export still contains a NetworkManager connection' >&2
exit 1
fi
KERNEL_ARCHIVE=axp313a-kernel-6.1.31-matrix-axp313a1.tar.gz
KERNEL_TARGET=$MOUNT_DIR/opt/matrix-image-bootstrap/axp313a
APP_TARGET=$MOUNT_DIR/opt/matrix-image-bootstrap/app/MSCBOOT.TGZ
PYTHONPATH="$SCRIPT_ROOT" python3 "$SCRIPT_ROOT/scripts/image_bootstrap_payload.py" verify \
--root "$MOUNT_DIR" --bundle "$APP_BUNDLE" --dependency "$KERNEL_DEPENDENCY" >/dev/null
[ ! -e "$BOOT_MOUNT/MSCBOOT.TGZ" ]
python3 - "$BOOT_MOUNT/MSCMETA.JSN" "$KERNEL_DEPENDENCY/METADATA.json" \
"$KERNEL_TARGET/$KERNEL_ARCHIVE" "$APP_TARGET" <<'PY'
import hashlib
import json
from pathlib import Path
import sys
image = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
kernel = json.loads(Path(sys.argv[2]).read_text(encoding="utf-8"))
artifact = Path(sys.argv[3])
bundle = Path(sys.argv[4])
assert image["format_version"] == 3
assert image["bundle_path"] == "/opt/matrix-image-bootstrap/app/MSCBOOT.TGZ"
assert image["bundle_bytes"] == bundle.stat().st_size
assert image["kernel_release"] == kernel["kernel_release"]
assert image["kernel_artifact"] == kernel["artifact"]
assert image["kernel_artifact_bytes"] == kernel["artifact_bytes"] == artifact.stat().st_size
assert image["kernel_artifact_sha256"] == kernel["artifact_sha256"]
assert image["kernel_unpacked_file_bytes"] == kernel["unpacked_file_bytes"]
digest = hashlib.sha256()
with artifact.open("rb") as handle:
for chunk in iter(lambda: handle.read(4 * 1024 * 1024), b""):
digest.update(chunk)
assert digest.hexdigest() == kernel["artifact_sha256"]
digest = hashlib.sha256()
with bundle.open("rb") as handle:
for chunk in iter(lambda: handle.read(4 * 1024 * 1024), b""):
digest.update(chunk)
assert digest.hexdigest() == image["bundle_sha256"]
assert image["boot_safety_bytes"] == 32 * 1024 * 1024
assert image["boot_required_bytes"] > 0
PY
printf '%s\n' 'rootfs bootstrap verified'