Harden RF node control and agent updates

This commit is contained in:
LittleSam129
2026-09-30 11:42:19 +03:00
parent a94f2db4ee
commit 0d6fff224e
34 changed files with 14010 additions and 0 deletions

View File

@@ -0,0 +1,334 @@
"""Безопасный запуск уже распакованного bootstrap-пакета RX-агента.
Утилита принадлежит MAIN и не входит в подписанный bootstrap. Она проверяет
HMAC и точный состав распакованного пакета до запуска, сохраняет stdout,
stderr и снимки дерева только во внешнем каталоге, а после завершения
подтверждает, что пакет не изменился.
"""
from __future__ import annotations
import argparse
from dataclasses import asdict, dataclass
from datetime import datetime, timezone
import hashlib
import hmac
import json
import os
from pathlib import Path, PurePosixPath
import re
import subprocess
from typing import Any, Sequence
MANIFEST_NAME = "BOOTSTRAP_MANIFEST.json"
SIGNATURE_NAME = "BOOTSTRAP_MANIFEST.hmac"
INSTALLER_NAME = "INSTALL_RX_AGENT.cmd"
SIGNATURE_DOMAIN = b"RF001-UPDATE-MANIFEST-V1\n"
SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$")
class BootstrapInstallError(RuntimeError):
"""Базовая ошибка безопасного запуска bootstrap."""
class PackageValidationError(BootstrapInstallError):
"""Пакет не прошёл HMAC, manifest или точную проверку состава."""
class EvidencePathError(BootstrapInstallError):
"""Каталог доказательств небезопасен или уже содержит журналы запуска."""
class PackageMutationError(BootstrapInstallError):
"""Дерево bootstrap изменилось во время запуска установщика."""
@dataclass(frozen=True)
class TreeEntry:
path: str
bytes: int
sha256: str
modified_time_ns: int
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat()
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
while block := stream.read(1024 * 1024):
digest.update(block)
return digest.hexdigest()
def snapshot_tree(root: Path) -> list[TreeEntry]:
entries: list[TreeEntry] = []
for path in sorted(
(candidate for candidate in root.rglob("*") if candidate.is_file()),
key=lambda candidate: candidate.as_posix().casefold(),
):
stat = path.stat()
entries.append(
TreeEntry(
path=path.relative_to(root).as_posix(),
bytes=stat.st_size,
sha256=sha256_file(path),
modified_time_ns=stat.st_mtime_ns,
)
)
return entries
def _content_identity(entries: Sequence[TreeEntry]) -> list[tuple[str, int, str]]:
return [(entry.path, entry.bytes, entry.sha256) for entry in entries]
def _write_json(path: Path, value: Any) -> None:
path.write_text(
json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
def _relative_manifest_path(value: object) -> str:
if not isinstance(value, str) or not value:
raise PackageValidationError("bootstrap manifest contains an empty path")
if "\\" in value or ":" in value:
raise PackageValidationError(f"unsafe bootstrap path: {value}")
relative = PurePosixPath(value)
if relative.is_absolute() or ".." in relative.parts or "." in relative.parts:
raise PackageValidationError(f"unsafe bootstrap path: {value}")
return relative.as_posix()
def validate_pristine_package(package_root: Path, token_file: Path) -> dict[str, Any]:
package_root = package_root.resolve()
token_file = token_file.resolve()
if not package_root.is_dir():
raise PackageValidationError("bootstrap package directory is absent")
manifest_path = package_root / MANIFEST_NAME
signature_path = package_root / SIGNATURE_NAME
installer_path = package_root / INSTALLER_NAME
for required in (manifest_path, signature_path, installer_path, token_file):
if not required.is_file():
raise PackageValidationError(f"required file is absent: {required}")
manifest_bytes = manifest_path.read_bytes()
token = token_file.read_text(encoding="utf-8").strip().encode("utf-8")
if len(token) < 32:
raise PackageValidationError("installed signing token is invalid")
supplied_signature = signature_path.read_text(encoding="ascii").strip().lower()
expected_signature = hmac.new(
token,
SIGNATURE_DOMAIN + manifest_bytes,
hashlib.sha256,
).hexdigest()
if not SHA256_PATTERN.fullmatch(supplied_signature) or not hmac.compare_digest(
supplied_signature,
expected_signature,
):
raise PackageValidationError("bootstrap manifest signature mismatch")
try:
manifest = json.loads(manifest_bytes.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as error:
raise PackageValidationError("bootstrap manifest is invalid JSON") from error
if not isinstance(manifest, dict):
raise PackageValidationError("bootstrap manifest root must be an object")
if manifest.get("schema_version") != 1 or manifest.get("package_kind") != "BOOTSTRAP":
raise PackageValidationError("unsupported bootstrap manifest")
rows = manifest.get("files")
if not isinstance(rows, list) or not rows:
raise PackageValidationError("bootstrap manifest file list is invalid")
expected: dict[str, tuple[int, str]] = {}
for row in rows:
if not isinstance(row, dict):
raise PackageValidationError("bootstrap manifest file row is invalid")
relative = _relative_manifest_path(row.get("path"))
folded = relative.casefold()
if folded in expected:
raise PackageValidationError(f"duplicate bootstrap path: {relative}")
byte_count = row.get("bytes")
digest = row.get("sha256")
if type(byte_count) is not int or byte_count < 0:
raise PackageValidationError(f"invalid bootstrap size: {relative}")
if not isinstance(digest, str) or not SHA256_PATTERN.fullmatch(digest.lower()):
raise PackageValidationError(f"invalid bootstrap SHA256: {relative}")
expected[folded] = (byte_count, digest.lower())
source = package_root.joinpath(*PurePosixPath(relative).parts)
if not source.is_file() or source.is_symlink():
raise PackageValidationError(f"bootstrap file is absent or unsafe: {relative}")
if source.stat().st_size != byte_count:
raise PackageValidationError(f"bootstrap file size mismatch: {relative}")
if sha256_file(source) != digest.lower():
raise PackageValidationError(f"bootstrap file SHA256 mismatch: {relative}")
actual_entries = snapshot_tree(package_root)
actual = {entry.path.casefold() for entry in actual_entries}
allowed = set(expected) | {MANIFEST_NAME.casefold(), SIGNATURE_NAME.casefold()}
unexpected = sorted(actual - allowed)
missing = sorted(allowed - actual)
if unexpected or missing:
raise PackageValidationError(
"bootstrap package allowlist mismatch; "
f"unexpected={unexpected}; missing={missing}"
)
return {
"bootstrap_id": manifest.get("bootstrap_id"),
"agent_release_id": manifest.get("agent_release_id"),
"agent_version": manifest.get("agent_version"),
"hmac": "PASS",
"manifest_file_count": len(expected),
"package_file_count": len(actual_entries),
"unexpected": 0,
"missing": 0,
}
def _assert_external_evidence(package_root: Path, evidence_root: Path) -> None:
package_root = package_root.resolve()
evidence_root = evidence_root.resolve()
if evidence_root == package_root or package_root in evidence_root.parents:
raise EvidencePathError("evidence directory must be outside the package tree")
def run_installer(
package_root: Path,
evidence_root: Path,
token_file: Path,
*,
command: Sequence[str] | None = None,
timeout_seconds: float | None = None,
) -> dict[str, Any]:
package_root = package_root.resolve()
evidence_root = evidence_root.resolve()
_assert_external_evidence(package_root, evidence_root)
preflight = validate_pristine_package(package_root, token_file)
evidence_root.mkdir(parents=True, exist_ok=True)
paths = {
"stdout": evidence_root / "install.stdout.txt",
"stderr": evidence_root / "install.stderr.txt",
"runtime": evidence_root / "install_runtime.json",
"before": evidence_root / "package_tree_before.json",
"immediate": evidence_root / "package_tree_immediate.json",
"after": evidence_root / "package_tree_after.json",
"preflight": evidence_root / "package_preflight.json",
}
occupied = [str(path) for path in paths.values() if path.exists()]
if occupied:
raise EvidencePathError(f"runtime evidence paths already exist: {occupied}")
before = snapshot_tree(package_root)
_write_json(paths["preflight"], preflight)
_write_json(paths["before"], [asdict(entry) for entry in before])
invocation = list(command) if command is not None else [
os.environ.get("COMSPEC", "cmd.exe"),
"/d",
"/c",
"call",
str(package_root / INSTALLER_NAME),
]
metadata: dict[str, Any] = {
"started_at_utc": utc_now(),
"package_root": str(package_root),
"evidence_root": str(evidence_root),
"working_directory": str(package_root),
"stdout_path": str(paths["stdout"]),
"stderr_path": str(paths["stderr"]),
"rf_rx": 0,
"rf_tx": 0,
}
creation_flags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
with paths["stdout"].open("wb") as stdout_stream, paths["stderr"].open(
"wb"
) as stderr_stream:
process = subprocess.Popen(
invocation,
cwd=package_root,
stdin=subprocess.DEVNULL,
stdout=stdout_stream,
stderr=stderr_stream,
creationflags=creation_flags,
)
metadata["installer_process_id"] = process.pid
immediate = snapshot_tree(package_root)
_write_json(paths["immediate"], [asdict(entry) for entry in immediate])
metadata["package_content_unchanged_immediate"] = (
_content_identity(immediate) == _content_identity(before)
)
metadata["package_metadata_unchanged_immediate"] = immediate == before
try:
exit_code = process.wait(timeout=timeout_seconds)
except subprocess.TimeoutExpired as error:
metadata["timed_out"] = True
metadata["completed_at_utc"] = utc_now()
_write_json(paths["runtime"], metadata)
raise BootstrapInstallError(
"installer timeout; the process was not restarted or killed automatically"
) from error
after = snapshot_tree(package_root)
_write_json(paths["after"], [asdict(entry) for entry in after])
metadata.update(
{
"completed_at_utc": utc_now(),
"exit_code": exit_code,
"timed_out": False,
"package_content_unchanged_after": (
_content_identity(after) == _content_identity(before)
),
"package_metadata_unchanged_after": after == before,
"stdout_bytes": paths["stdout"].stat().st_size,
"stderr_bytes": paths["stderr"].stat().st_size,
}
)
_write_json(paths["runtime"], metadata)
if not metadata["package_content_unchanged_immediate"] or not metadata[
"package_content_unchanged_after"
]:
raise PackageMutationError("bootstrap package changed during installer run")
if exit_code != 0:
raise BootstrapInstallError(f"installer exited with code {exit_code}")
return metadata
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--package-root", required=True, type=Path)
parser.add_argument("--evidence-root", required=True, type=Path)
parser.add_argument("--token-file", required=True, type=Path)
parser.add_argument("--timeout-seconds", type=float, default=None)
return parser.parse_args()
def main() -> int:
arguments = parse_args()
try:
result = run_installer(
arguments.package_root,
arguments.evidence_root,
arguments.token_file,
timeout_seconds=arguments.timeout_seconds,
)
except BootstrapInstallError as error:
print(f"BOOTSTRAP INSTALL = FAILED: {error}")
print("RF RX = 0")
print("RF TX = 0")
return 1
print("BOOTSTRAP INSTALL = COMPLETE")
print(f"INSTALL EXIT CODE = {result['exit_code']}")
print(f"EVIDENCE ROOT = {result['evidence_root']}")
print("PACKAGE TREE UNCHANGED = YES")
print("RF RX = 0")
print("RF TX = 0")
return 0
if __name__ == "__main__":
raise SystemExit(main())