diff --git a/.gitattributes b/.gitattributes index b2ec61b1..3b002266 100644 --- a/.gitattributes +++ b/.gitattributes @@ -10,10 +10,13 @@ *.conf text eol=lf *.ps1 text eol=crlf +build/usb/version.env text eol=lf build/usb/hooks/* text eol=lf build/usb/package-lists/rigos.list.chroot text eol=lf build/usb/includes.chroot/usr/local/bin/* text eol=lf build/usb/includes.chroot/usr/local/sbin/rigos-* text eol=lf +build/usb/includes.chroot/usr/lib/rigos/rigos-randomx-msr text eol=lf build/usb/includes.chroot/usr/lib/rigos/rigos-runtime-* text eol=lf build/usb/includes.chroot/usr/lib/rigos/rigos-miner-* text eol=lf build/usb/includes.chroot/usr/lib/rigos/rigos-remote-* text eol=lf +build/usb/includes.chroot/usr/lib/rigos/rigos-lifecycle-* text eol=lf diff --git a/.gitignore b/.gitignore index 19d2cb47..11610b1e 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,5 @@ *.age.partial *.pem *.key +__pycache__/ +*.py[cod] diff --git a/build/usb/Dockerfile b/build/usb/Dockerfile index be63222a..02958f38 100644 --- a/build/usb/Dockerfile +++ b/build/usb/Dockerfile @@ -14,4 +14,4 @@ RUN command -v cargo \ && rustc --version WORKDIR /source -ENTRYPOINT ["/bin/bash", "-c", "python3 ./scripts/check-alpha8-ssh-hotfix.py && exec ./scripts/build-usb-image.sh"] +ENTRYPOINT ["/bin/bash", "-c", "exec /bin/bash ./scripts/build-usb-image-entrypoint.sh"] diff --git a/build/usb/hooks/010-rigos.chroot b/build/usb/hooks/010-rigos.chroot index a29b316f..acc1d24b 100644 --- a/build/usb/hooks/010-rigos.chroot +++ b/build/usb/hooks/010-rigos.chroot @@ -4,12 +4,13 @@ set -eu useradd --system --home-dir /var/lib/rigos --shell /usr/sbin/nologin --user-group rigos useradd --create-home --shell /bin/bash --groups sudo rigosadmin passwd --lock rigosadmin -install -d -o rigos -g rigos -m 0750 /var/lib/rigos +install -d -o root -g rigos -m 0750 /var/lib/rigos install -d -m 0755 /usr/lib/rigos /usr/local/bin systemd-tmpfiles --create /usr/lib/tmpfiles.d/rigos.conf -chmod 0755 /usr/local/bin/rigosd /usr/local/bin/rigosctl /usr/local/sbin/rigos-firstboot /usr/local/sbin/rigos-recovery-access /usr/local/sbin/rigos-state-orchestrate /usr/lib/rigos/rigos-miner-gate /usr/lib/rigos/rigos-miner-health /usr/lib/rigos/rigos-runtime-render /usr/lib/rigos/rigos-runtime-publish /usr/lib/rigos/rigos-runtime-gate /usr/lib/rigos/rigos-state-init /usr/lib/rigos/rigos-state-ready /usr/lib/rigos/rigos-config /usr/lib/rigos/rigos-performance /usr/lib/rigos/rigos-lifecycle-cycles /usr/lib/rigos/rigos-identity-seed /usr/lib/rigos/xmrig -systemctl enable NetworkManager.service ssh.service rigos-state.service rigos-recovery-access.service rigos-state-ready.service rigos-profile-apply.service rigos-runtime-render.service rigos-hugepages.service rigos-firstboot.service rigos-miner.service rigos-miner-health.timer tmp.mount +rm -f /etc/ssh/ssh_host_*_key /etc/ssh/ssh_host_*_key.pub +chmod 0755 /usr/local/bin/rigosd /usr/local/bin/rigosctl /usr/local/sbin/rigos-firstboot /usr/local/sbin/rigos-recovery-access /usr/local/sbin/rigos-state-orchestrate /usr/lib/rigos/rigos-miner-gate /usr/lib/rigos/rigos-miner-health /usr/lib/rigos/rigos-runtime-render /usr/lib/rigos/rigos-runtime-publish /usr/lib/rigos/rigos-runtime-gate /usr/lib/rigos/rigos-randomx-msr /usr/lib/rigos/rigos-ssh-hostkeys /usr/lib/rigos/rigos-state-init /usr/lib/rigos/rigos-state-ready /usr/lib/rigos/rigos-config /usr/lib/rigos/rigos-performance /usr/lib/rigos/rigos-lifecycle-cycles /usr/lib/rigos/rigos-identity-seed /usr/lib/rigos/xmrig +systemctl enable NetworkManager.service ssh.service rigos-state.service rigos-recovery-access.service rigos-state-ready.service rigos-ssh-hostkeys.service rigos-profile-apply.service rigos-runtime-render.service rigos-hugepages.service rigos-firstboot.service rigos-randomx-msr.service rigos-miner.service rigos-miner-health.timer tmp.mount systemctl disable ssh.socket 2>/dev/null || true systemctl disable apt-daily.timer apt-daily-upgrade.timer logrotate.timer fstrim.timer 2>/dev/null || true systemctl disable systemd-journald-audit.socket 2>/dev/null || true diff --git a/build/usb/includes.chroot/etc/ssh/sshd_config.d/01-rigos-hostkeys.conf b/build/usb/includes.chroot/etc/ssh/sshd_config.d/01-rigos-hostkeys.conf new file mode 100644 index 00000000..0532202d --- /dev/null +++ b/build/usb/includes.chroot/etc/ssh/sshd_config.d/01-rigos-hostkeys.conf @@ -0,0 +1 @@ +HostKey /var/lib/rigos/system/ssh-hostkeys/ssh_host_ed25519_key diff --git a/build/usb/includes.chroot/etc/systemd/system/rigos-miner-health.service b/build/usb/includes.chroot/etc/systemd/system/rigos-miner-health.service index bd3523ea..9715a89e 100644 --- a/build/usb/includes.chroot/etc/systemd/system/rigos-miner-health.service +++ b/build/usb/includes.chroot/etc/systemd/system/rigos-miner-health.service @@ -12,7 +12,9 @@ ProtectSystem=strict ProtectKernelTunables=yes ProtectKernelModules=yes ProtectControlGroups=yes -RestrictAddressFamilies=AF_UNIX +RestrictAddressFamilies=AF_UNIX AF_INET +IPAddressDeny=any +IPAddressAllow=127.0.0.0/8 RestrictNamespaces=yes LockPersonality=yes ReadWritePaths=/run/rigos diff --git a/build/usb/includes.chroot/etc/systemd/system/rigos-miner.service.d/randomx-msr.conf b/build/usb/includes.chroot/etc/systemd/system/rigos-miner.service.d/randomx-msr.conf new file mode 100644 index 00000000..e90c9d0b --- /dev/null +++ b/build/usb/includes.chroot/etc/systemd/system/rigos-miner.service.d/randomx-msr.conf @@ -0,0 +1,3 @@ +[Unit] +Wants=rigos-randomx-msr.service +After=rigos-randomx-msr.service diff --git a/build/usb/includes.chroot/etc/systemd/system/rigos-randomx-msr.service b/build/usb/includes.chroot/etc/systemd/system/rigos-randomx-msr.service new file mode 100644 index 00000000..25ba6c4c --- /dev/null +++ b/build/usb/includes.chroot/etc/systemd/system/rigos-randomx-msr.service @@ -0,0 +1,32 @@ +[Unit] +Description=Apply reversible RIGOS RandomX MSR optimization +After=rigos-state-ready.service rigos-profile-apply.service +Requires=rigos-state-ready.service rigos-profile-apply.service +Before=rigos-miner.service +ConditionArchitecture=x86-64 +ConditionPathExists=/var/lib/rigos/current + +[Service] +Type=oneshot +ExecStartPre=-/usr/sbin/modprobe msr +ExecStart=/usr/bin/python3 /usr/lib/rigos/rigos-randomx-msr apply +ExecStop=/usr/bin/python3 /usr/lib/rigos/rigos-randomx-msr restore +RemainAfterExit=yes +TimeoutStartSec=20s +TimeoutStopSec=20s +NoNewPrivileges=yes +PrivateTmp=yes +ProtectHome=yes +ProtectSystem=strict +ProtectKernelTunables=yes +ProtectKernelModules=no +ProtectControlGroups=yes +RestrictAddressFamilies=AF_UNIX +RestrictNamespaces=yes +LockPersonality=yes +CapabilityBoundingSet=CAP_SYS_MODULE CAP_SYS_RAWIO +AmbientCapabilities=CAP_SYS_MODULE CAP_SYS_RAWIO +ReadWritePaths=/run/rigos -/dev/cpu + +[Install] +WantedBy=multi-user.target diff --git a/build/usb/includes.chroot/etc/systemd/system/rigos-ssh-hostkeys.service b/build/usb/includes.chroot/etc/systemd/system/rigos-ssh-hostkeys.service new file mode 100644 index 00000000..fe069df2 --- /dev/null +++ b/build/usb/includes.chroot/etc/systemd/system/rigos-ssh-hostkeys.service @@ -0,0 +1,25 @@ +[Unit] +Description=Establish persistent RIGOS SSH host identity +After=rigos-state-ready.service +Requires=rigos-state-ready.service +Before=ssh.service + +[Service] +Type=oneshot +ExecStart=/usr/lib/rigos/rigos-ssh-hostkeys +RemainAfterExit=yes +UMask=0077 +NoNewPrivileges=yes +PrivateTmp=yes +ProtectHome=yes +ProtectSystem=strict +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectControlGroups=yes +RestrictAddressFamilies=AF_UNIX +RestrictNamespaces=yes +LockPersonality=yes +ReadWritePaths=/var/lib/rigos /run/rigos + +[Install] +WantedBy=multi-user.target diff --git a/build/usb/includes.chroot/etc/systemd/system/rigos-state-ready.service b/build/usb/includes.chroot/etc/systemd/system/rigos-state-ready.service index 5fbc72e0..593b274c 100644 --- a/build/usb/includes.chroot/etc/systemd/system/rigos-state-ready.service +++ b/build/usb/includes.chroot/etc/systemd/system/rigos-state-ready.service @@ -2,7 +2,7 @@ Description=Verify RIGOS persistent state readiness After=rigos-state.service rigos-recovery-access.service Requires=rigos-state.service -Before=rigos-profile-apply.service rigos-firstboot.service rigos-hugepages.service rigos-miner.service +Before=rigos-ssh-hostkeys.service rigos-profile-apply.service rigos-firstboot.service rigos-hugepages.service rigos-miner.service [Service] Type=oneshot diff --git a/build/usb/includes.chroot/etc/systemd/system/ssh.service.d/rigos-observe.conf b/build/usb/includes.chroot/etc/systemd/system/ssh.service.d/rigos-observe.conf index d00954c6..28ca00d7 100644 --- a/build/usb/includes.chroot/etc/systemd/system/ssh.service.d/rigos-observe.conf +++ b/build/usb/includes.chroot/etc/systemd/system/ssh.service.d/rigos-observe.conf @@ -1,3 +1,4 @@ [Unit] -After=rigos-recovery-access.service +After=rigos-recovery-access.service rigos-ssh-hostkeys.service +Requires=rigos-ssh-hostkeys.service Wants=rigos-remote-access-observe.service diff --git a/build/usb/includes.chroot/usr/lib/rigos/rigos-lifecycle-cycles b/build/usb/includes.chroot/usr/lib/rigos/rigos-lifecycle-cycles index aae63039..5d452324 100644 --- a/build/usb/includes.chroot/usr/lib/rigos/rigos-lifecycle-cycles +++ b/build/usb/includes.chroot/usr/lib/rigos/rigos-lifecycle-cycles @@ -1,4 +1,5 @@ #!/bin/sh +# Repository contract: LF line endings are required for appliance execution. set -eu cycles="${1:-20}" diff --git a/build/usb/includes.chroot/usr/lib/rigos/rigos-miner-gate b/build/usb/includes.chroot/usr/lib/rigos/rigos-miner-gate index 4264ee5f..8d80131e 100644 --- a/build/usb/includes.chroot/usr/lib/rigos/rigos-miner-gate +++ b/build/usb/includes.chroot/usr/lib/rigos/rigos-miner-gate @@ -5,6 +5,12 @@ import sys from pathlib import Path MAX_JSON_BYTES = 2 * 1024 * 1024 +PRODUCTION_STATE = Path("/var/lib/rigos") +DEFAULT_MSR_STATUS = Path("/run/rigos/randomx-msr-status.json") +DEFAULT_MSR_STATE = Path("/run/rigos/randomx-msr-state.json") +DEFAULT_BOOT_ID = Path("/proc/sys/kernel/random/boot_id") +MSR_REGISTER = "0x00000000000001a4" +MSR_TARGET = "0x000000000000000f" def emit(outcome: str, reason: str | None = None) -> None: @@ -32,10 +38,71 @@ def deny(reason: str) -> int: return 2 +def validate_msr_authority( + status_path: Path, + state_path: Path, + boot_id_path: Path, +) -> str | None: + try: + status = read_object(status_path) + boot_id = boot_id_path.read_text(encoding="ascii").strip() + except (OSError, UnicodeError, ValueError, json.JSONDecodeError): + return "randomx_msr_status_unreadable" + + if not boot_id: + return "randomx_msr_boot_id_unreadable" + if status.get("schema") != "rigos.randomx-msr-status/v1": + return "randomx_msr_status_schema_invalid" + if status.get("boot_id") != boot_id: + return "randomx_msr_status_stale" + if status.get("register") != MSR_REGISTER: + return "randomx_msr_register_invalid" + if status.get("target_value") != MSR_TARGET: + return "randomx_msr_target_invalid" + + outcome = status.get("outcome") + if outcome == "ready": + if not state_path.exists(): + return "randomx_msr_restore_state_missing" + return None + + if outcome in {"unsupported", "unavailable"}: + if state_path.exists(): + return "randomx_msr_baseline_has_mutation_state" + return None + + if ( + outcome == "degraded" + and status.get("reason") == "apply_failed_rolled_back" + and status.get("rollback") == {"attempted": True, "complete": True} + ): + if state_path.exists(): + return "randomx_msr_rollback_state_present" + return None + + return "randomx_msr_authority_unsafe" + + +def resolve_msr_paths(args: argparse.Namespace) -> tuple[Path, Path, Path] | None: + supplied = (args.msr_status, args.msr_state, args.boot_id) + if any(value is not None for value in supplied): + if not all(value is not None for value in supplied): + raise ValueError("MSR fixture paths must be supplied together") + return args.msr_status, args.msr_state, args.boot_id + + if args.state == PRODUCTION_STATE: + return DEFAULT_MSR_STATUS, DEFAULT_MSR_STATE, DEFAULT_BOOT_ID + + return None + + def main() -> int: parser = argparse.ArgumentParser() - parser.add_argument("--state", type=Path, default=Path("/var/lib/rigos")) + parser.add_argument("--state", type=Path, default=PRODUCTION_STATE) parser.add_argument("--cmdline", type=Path, default=Path("/proc/cmdline")) + parser.add_argument("--msr-status", type=Path) + parser.add_argument("--msr-state", type=Path) + parser.add_argument("--boot-id", type=Path) args = parser.parse_args() policy_path = args.state / "current" / "policy.json" @@ -65,6 +132,16 @@ def main() -> int: except (OSError, ValueError, json.JSONDecodeError): return deny("xmrig_config_unreadable") + try: + msr_paths = resolve_msr_paths(args) + except ValueError: + return deny("randomx_msr_fixture_paths_incomplete") + + if msr_paths is not None: + msr_reason = validate_msr_authority(*msr_paths) + if msr_reason is not None: + return deny(msr_reason) + emit("allowed") return 0 diff --git a/build/usb/includes.chroot/usr/lib/rigos/rigos-miner-health b/build/usb/includes.chroot/usr/lib/rigos/rigos-miner-health index 39221147..b8cf8733 100644 --- a/build/usb/includes.chroot/usr/lib/rigos/rigos-miner-health +++ b/build/usb/includes.chroot/usr/lib/rigos/rigos-miner-health @@ -1,8 +1,11 @@ #!/usr/bin/python3 import datetime as dt +import http.client import json +import math import os import re +import stat import subprocess import sys import uuid @@ -15,10 +18,21 @@ PROC = Path(os.environ.get("RIGOS_PROC_ROOT", "/proc")) SYSTEMCTL = os.environ.get("RIGOS_SYSTEMCTL", "/usr/bin/systemctl") JOURNALCTL = os.environ.get("RIGOS_JOURNALCTL", "/usr/bin/journalctl") STATUS = RUNTIME / "miner-health-status.json" +API_TOKEN = Path(os.environ.get("RIGOS_XMRIG_API_TOKEN_PATH", str(RUNTIME / "xmrig-api-token"))) +API_HOST = os.environ.get("RIGOS_XMRIG_API_HOST", "127.0.0.1") +API_PORT = int(os.environ.get("RIGOS_XMRIG_API_PORT", "18080")) +API_TIMEOUT_SECONDS = float(os.environ.get("RIGOS_XMRIG_API_TIMEOUT_SECONDS", "2")) +API_MAX_BYTES = 256 * 1024 +TEST_MODE = os.environ.get("RIGOS_MINER_HEALTH_TEST_MODE") == "1" WINDOW_SECONDS = int(os.environ.get("RIGOS_MINER_HEALTH_WINDOW_SECONDS", "180")) WARMUP_SECONDS = int(os.environ.get("RIGOS_MINER_WARMUP_SECONDS", "240")) MAX_JOURNAL_LINES = 500 +TOKEN_RE = re.compile(r"^[A-Za-z0-9_-]{32,128}$") +READY_MARKERS = ( + "miner speed", + "accepted (", +) EXTERNAL_MARKERS = ( "connect error", "connection refused", @@ -43,7 +57,7 @@ def atomic_json(path: Path, value: dict) -> None: handle.flush() os.fsync(handle.fileno()) os.replace(temporary, path) - directory = os.open(path.parent, os.O_RDONLY) + directory = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY) try: os.fsync(directory) finally: @@ -155,6 +169,195 @@ def last_share_counts(text: str) -> tuple[int | None, int | None]: return int(accepted), int(rejected) +def latest_marker_index(text: str, markers: tuple[str, ...]) -> int: + return max((text.rfind(marker) for marker in markers), default=-1) + + +def latest_journal_signal(text: str) -> str | None: + lowered = text.lower() + ready_index = latest_marker_index(lowered, READY_MARKERS) + external_index = latest_marker_index(lowered, EXTERNAL_MARKERS) + if ready_index < 0 and external_index < 0: + return None + return "external_wait" if external_index > ready_index else "ready" + + +def journal_fallback_state( + journal: str, + active_for: int | None, + api_error: str | None, +) -> tuple[str, str | None]: + signal = latest_journal_signal(journal) + if signal == "ready": + return "ready", None + if signal == "external_wait": + return "waiting_external", "pool_or_network_unavailable" + if active_for is not None and active_for < WARMUP_SECONDS: + return "warming_up", None + return "degraded", api_error or "miner_api_unavailable" + + +def expected_token_uid() -> int: + return os.geteuid() if TEST_MODE else 0 + + +def read_api_token() -> str: + nofollow = getattr(os, "O_NOFOLLOW", 0) + descriptor = os.open(API_TOKEN, os.O_RDONLY | os.O_CLOEXEC | nofollow) + try: + observed = os.fstat(descriptor) + if not stat.S_ISREG(observed.st_mode): + raise RuntimeError("api_token_not_regular") + if observed.st_uid != expected_token_uid(): + raise RuntimeError("api_token_owner_unsafe") + if stat.S_IMODE(observed.st_mode) != 0o600: + raise RuntimeError("api_token_mode_unsafe") + if observed.st_size > 256: + raise RuntimeError("api_token_oversized") + raw = os.read(descriptor, 257) + finally: + os.close(descriptor) + try: + token = raw.decode("ascii").strip() + except UnicodeDecodeError as error: + raise RuntimeError("api_token_not_ascii") from error + if not TOKEN_RE.fullmatch(token): + raise RuntimeError("api_token_invalid") + return token + + +def fetch_api_summary() -> tuple[dict | None, str | None]: + connection = None + try: + token = read_api_token() + connection = http.client.HTTPConnection( + API_HOST, + API_PORT, + timeout=API_TIMEOUT_SECONDS, + ) + connection.request( + "GET", + "/2/summary", + headers={ + "Accept": "application/json", + "Authorization": f"Bearer {token}", + "Connection": "close", + }, + ) + response = connection.getresponse() + if response.status != 200: + response.read(min(API_MAX_BYTES, 4096)) + return None, f"api_http_status_{response.status}" + length = response.getheader("Content-Length") + if length is not None: + try: + if int(length) > API_MAX_BYTES: + return None, "api_response_oversized" + except ValueError: + return None, "api_content_length_invalid" + raw = response.read(API_MAX_BYTES + 1) + if len(raw) > API_MAX_BYTES: + return None, "api_response_oversized" + value = json.loads(raw) + if not isinstance(value, dict): + return None, "api_response_not_object" + return value, None + except FileNotFoundError: + return None, "api_token_missing" + except PermissionError: + return None, "api_token_permission_denied" + except (OSError, RuntimeError, http.client.HTTPException, json.JSONDecodeError) as error: + message = str(error) + if message.startswith("api_"): + return None, message + return None, "api_unavailable" + finally: + if connection is not None: + connection.close() + + +def nonnegative_number(value: object) -> int | float | None: + if isinstance(value, bool) or not isinstance(value, (int, float)): + return None + if not math.isfinite(float(value)) or value < 0: + return None + return value + + +def nonnegative_integer(value: object) -> int | None: + if isinstance(value, bool) or not isinstance(value, int) or value < 0: + return None + return value + + +def summary_metrics(summary: dict) -> dict: + hashrate = summary.get("hashrate") + if not isinstance(hashrate, dict): + hashrate = {} + raw_total = hashrate.get("total") + rates: list[int | float | None] = [None, None, None] + if isinstance(raw_total, list): + for index, value in enumerate(raw_total[:3]): + rates[index] = nonnegative_number(value) + + connection = summary.get("connection") + if not isinstance(connection, dict): + connection = {} + results = summary.get("results") + if not isinstance(results, dict): + results = {} + + accepted = nonnegative_integer(connection.get("accepted")) + rejected = nonnegative_integer(connection.get("rejected")) + if accepted is None: + accepted = nonnegative_integer(results.get("shares_good")) + if rejected is None: + total = nonnegative_integer(results.get("shares_total")) + if total is not None and accepted is not None and total >= accepted: + rejected = total - accepted + + pool = connection.get("pool") + if not isinstance(pool, str) or not pool: + pool = None + connection_ip = connection.get("ip") + if not isinstance(connection_ip, str) or not connection_ip: + connection_ip = None + connection_uptime_seconds = nonnegative_integer(connection.get("uptime")) + connection_uptime_ms = nonnegative_integer(connection.get("uptime_ms")) + pool_connected = pool is not None and ( + connection_ip is not None + or (connection_uptime_ms is not None and connection_uptime_ms > 0) + or (connection_uptime_seconds is not None and connection_uptime_seconds > 0) + ) + + hugepages = summary.get("hugepages") + hugepages_used = None + hugepages_total = None + if isinstance(hugepages, list) and len(hugepages) >= 2: + hugepages_used = nonnegative_integer(hugepages[0]) + hugepages_total = nonnegative_integer(hugepages[1]) + + return { + "uptime_seconds": nonnegative_integer(summary.get("uptime")), + "algorithm": summary.get("algo") if isinstance(summary.get("algo"), str) else None, + "hashrate_10s": rates[0], + "hashrate_60s": rates[1], + "hashrate_15m": rates[2], + "hashrate_highest": nonnegative_number(hashrate.get("highest")), + "pool_connected": pool_connected, + "pool": pool, + "connection_ip": connection_ip, + "connection_uptime_seconds": connection_uptime_seconds, + "connection_uptime_ms": connection_uptime_ms, + "connection_failures": nonnegative_integer(connection.get("failures")), + "pool_ping_ms": nonnegative_integer(connection.get("ping")), + "accepted_shares": accepted, + "rejected_shares": rejected, + "hugepages_used": hugepages_used, + "hugepages_total": hugepages_total, + } + + def classify( properties: dict[str, str], proc_state: str | None, @@ -162,6 +365,8 @@ def classify( canonical_revision: str | None, rendered_outcome: str | None, rendered_revision: str | None, + api_metrics: dict | None, + api_error: str | None, journal: str, journal_available: bool, ) -> tuple[str, str | None]: @@ -177,17 +382,30 @@ def classify( return "blocked", "current_revision_unavailable" if rendered_outcome != "ready" or rendered_revision != canonical_revision: return "blocked", "runtime_revision_mismatch" - if not journal_available: - return "unknown", "journal_unavailable" - lowered = journal.lower() - if "miner speed" in lowered: - return "ready", None - if any(marker in lowered for marker in EXTERNAL_MARKERS): - return "waiting_external", "pool_or_network_unavailable" - if active_for is not None and active_for < WARMUP_SECONDS: - return "warming_up", None - return "degraded", "no_recent_speed_evidence" + if api_metrics is not None: + current_hashrate = api_metrics.get("hashrate_10s") + current_hashrate_positive = ( + isinstance(current_hashrate, (int, float)) + and not isinstance(current_hashrate, bool) + and current_hashrate > 0 + ) + pool_connected = api_metrics.get("pool_connected") is True + if pool_connected and current_hashrate_positive: + return "ready", None + if active_for is not None and active_for < WARMUP_SECONDS: + return "warming_up", None + if not pool_connected: + return "waiting_external", "pool_or_network_unavailable" + if current_hashrate is None: + return "degraded", "current_hashrate_unavailable" + return "degraded", "no_current_hashrate_from_api" + + if api_error not in (None, "api_unavailable"): + return "degraded", api_error + if not journal_available: + return "unknown", api_error or "journal_unavailable" + return journal_fallback_state(journal, active_for, api_error) def main() -> int: @@ -204,8 +422,10 @@ def main() -> int: active_for = active_seconds(properties) canonical = current_revision() rendered_outcome, rendered_revision = runtime_revision() + summary, api_error = fetch_api_summary() + metrics = summary_metrics(summary) if summary is not None else None journal, journal_available = recent_journal() - accepted, rejected = last_share_counts(journal) + journal_accepted, journal_rejected = last_share_counts(journal) state, reason = classify( properties, proc_state, @@ -213,10 +433,14 @@ def main() -> int: canonical, rendered_outcome, rendered_revision, + metrics, + api_error, journal, journal_available, ) lowered_journal = journal.lower() + accepted = metrics.get("accepted_shares") if metrics is not None else journal_accepted + rejected = metrics.get("rejected_shares") if metrics is not None else journal_rejected value = { "schema": "rigos.miner-health-status/v1", "generated_at": dt.datetime.now(dt.timezone.utc).isoformat(timespec="milliseconds"), @@ -238,7 +462,14 @@ def main() -> int: "runtime_outcome": rendered_outcome, "runtime_revision": rendered_revision, }, + "api": { + "available": metrics is not None, + "error": api_error, + "endpoint": f"http://{API_HOST}:{API_PORT}/2/summary", + "metrics": metrics, + }, "evidence": { + "source": "xmrig_http_api" if metrics is not None else "journal_fallback", "window_seconds": WINDOW_SECONDS, "journal_available": journal_available, "journal_line_limit": MAX_JOURNAL_LINES, @@ -246,6 +477,7 @@ def main() -> int: "external_wait_marker": any( marker in lowered_journal for marker in EXTERNAL_MARKERS ), + "latest_journal_signal": latest_journal_signal(journal), "accepted_shares": accepted, "rejected_shares": rejected, }, @@ -256,6 +488,7 @@ def main() -> int: "schema": "rigos.miner-health-observer/v1", "state": state, "reason": reason, + "evidence_source": value["evidence"]["source"], "remediation": "observe_only", }, sort_keys=True)) return 0 diff --git a/build/usb/includes.chroot/usr/lib/rigos/rigos-randomx-msr b/build/usb/includes.chroot/usr/lib/rigos/rigos-randomx-msr new file mode 100644 index 00000000..27009d6f --- /dev/null +++ b/build/usb/includes.chroot/usr/lib/rigos/rigos-randomx-msr @@ -0,0 +1,440 @@ +#!/usr/bin/python3 +# Repository contract: LF line endings are required for appliance execution. +import argparse +import datetime as dt +import fcntl +import json +import os +import struct +import sys +import uuid +from pathlib import Path + +STATUS_SCHEMA = "rigos.randomx-msr-status/v1" +STATE_SCHEMA = "rigos.randomx-msr-state/v1" +REGISTER = 0x1A4 +TARGET_VALUE = 0xF +SUPPORTED_CPUS = {("GenuineIntel", 6, 42)} + + +class AuthorityError(RuntimeError): + pass + + +def utc_now() -> str: + return dt.datetime.now(dt.timezone.utc).isoformat(timespec="milliseconds") + + +def value_hex(value: int) -> str: + return f"0x{value:016x}" + + +def read_trimmed(path: Path, name: str) -> str: + try: + value = path.read_text(encoding="ascii").strip() + except (OSError, UnicodeError) as error: + raise AuthorityError(f"{name}: {error}") from error + if not value: + raise AuthorityError(f"{name} is empty") + return value + + +def parse_cpu_identity(path: Path) -> tuple[str, int, int]: + try: + text = path.read_text(encoding="ascii") + except (OSError, UnicodeError) as error: + raise AuthorityError(f"cpuinfo: {error}") from error + first = text.split("\n\n", 1)[0] + fields: dict[str, str] = {} + for line in first.splitlines(): + if ":" not in line: + continue + key, value = line.split(":", 1) + fields[key.strip()] = value.strip() + vendor = fields.get("vendor_id") + family = fields.get("cpu family") + model = fields.get("model") + if not vendor or family is None or model is None: + raise AuthorityError("cpu identity fields are incomplete") + try: + return vendor, int(family, 10), int(model, 10) + except ValueError as error: + raise AuthorityError("cpu family/model is malformed") from error + + +def parse_online_cpus(text: str) -> list[int]: + value = text.strip() + if not value: + raise AuthorityError("online CPU list is empty") + cpus: set[int] = set() + for item in value.split(","): + item = item.strip() + if not item: + raise AuthorityError("online CPU list contains an empty item") + if "-" in item: + start_text, end_text = item.split("-", 1) + try: + start = int(start_text, 10) + end = int(end_text, 10) + except ValueError as error: + raise AuthorityError("online CPU range is malformed") from error + if start < 0 or end < start: + raise AuthorityError("online CPU range is invalid") + cpus.update(range(start, end + 1)) + else: + try: + cpu = int(item, 10) + except ValueError as error: + raise AuthorityError("online CPU identifier is malformed") from error + if cpu < 0: + raise AuthorityError("online CPU identifier is invalid") + cpus.add(cpu) + if not cpus: + raise AuthorityError("online CPU list resolved to no CPUs") + return sorted(cpus) + + +def msr_path(device_root: Path, cpu: int) -> Path: + return device_root / str(cpu) / "msr" + + +def read_msr(device_root: Path, cpu: int, register: int = REGISTER) -> int: + path = msr_path(device_root, cpu) + try: + fd = os.open(path, os.O_RDONLY | os.O_CLOEXEC) + except OSError as error: + raise AuthorityError(f"open {path}: {error}") from error + try: + data = os.pread(fd, 8, register) + except OSError as error: + raise AuthorityError(f"read CPU {cpu} MSR {register:#x}: {error}") from error + finally: + os.close(fd) + if len(data) != 8: + raise AuthorityError(f"short read from CPU {cpu} MSR {register:#x}") + return struct.unpack(" None: + path = msr_path(device_root, cpu) + try: + fd = os.open(path, os.O_RDWR | os.O_CLOEXEC) + except OSError as error: + raise AuthorityError(f"open {path} for write: {error}") from error + try: + written = os.pwrite(fd, struct.pack(" None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp") + data = json.dumps(value, sort_keys=True, separators=(",", ":")) + "\n" + fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_CLOEXEC, mode) + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + handle.write(data) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, path) + directory = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC) + try: + os.fsync(directory) + finally: + os.close(directory) + finally: + try: + temporary.unlink() + except FileNotFoundError: + pass + + +def unlink_synced(path: Path) -> None: + try: + path.unlink() + except FileNotFoundError: + return + directory = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC) + try: + os.fsync(directory) + finally: + os.close(directory) + + +def load_state(path: Path) -> dict | None: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError: + return None + except (OSError, UnicodeError, json.JSONDecodeError) as error: + raise AuthorityError(f"MSR state is unreadable: {error}") from error + if not isinstance(value, dict) or value.get("schema") != STATE_SCHEMA: + raise AuthorityError("MSR state schema is invalid") + if value.get("register") != value_hex(REGISTER): + raise AuthorityError("MSR state register is invalid") + entries = value.get("cpus") + if not isinstance(entries, list) or not entries: + raise AuthorityError("MSR state CPU list is invalid") + for entry in entries: + if not isinstance(entry, dict): + raise AuthorityError("MSR state CPU entry is invalid") + if not isinstance(entry.get("cpu"), int) or not isinstance(entry.get("original"), str): + raise AuthorityError("MSR state CPU value is invalid") + try: + int(entry["original"], 16) + except ValueError as error: + raise AuthorityError("MSR state original value is malformed") from error + return value + + +def base_status(boot_id: str, vendor: str | None, family: int | None, model: int | None) -> dict: + return { + "schema": STATUS_SCHEMA, + "generated_at": utc_now(), + "boot_id": boot_id, + "vendor_id": vendor, + "cpu_family": family, + "cpu_model": model, + "register": value_hex(REGISTER), + "target_value": value_hex(TARGET_VALUE), + } + + +def publish(status_path: Path, value: dict) -> None: + atomic_json(status_path, value, 0o644) + print(json.dumps(value, sort_keys=True, separators=(",", ":"))) + + +def restore_values(device_root: Path, state: dict) -> tuple[bool, list[str], list[dict]]: + errors: list[str] = [] + evidence: list[dict] = [] + entries = state["cpus"] + for entry in entries: + cpu = entry["cpu"] + original = int(entry["original"], 16) + try: + write_msr(device_root, cpu, original) + except AuthorityError as error: + errors.append(str(error)) + for entry in entries: + cpu = entry["cpu"] + original = int(entry["original"], 16) + try: + readback = read_msr(device_root, cpu) + evidence.append({ + "cpu": cpu, + "original": value_hex(original), + "readback": value_hex(readback), + }) + if readback != original: + errors.append( + f"CPU {cpu} restore readback mismatch: expected {value_hex(original)} got {value_hex(readback)}" + ) + except AuthorityError as error: + errors.append(str(error)) + return not errors, errors, evidence + + +def current_values_match_target(device_root: Path, state: dict) -> bool: + try: + return all( + read_msr(device_root, entry["cpu"]) == TARGET_VALUE + for entry in state["cpus"] + ) + except AuthorityError: + return False + + +def apply(args: argparse.Namespace) -> int: + boot_id = read_trimmed(args.boot_id, "boot ID") + vendor, family, model = parse_cpu_identity(args.cpuinfo) + cpus = parse_online_cpus(read_trimmed(args.online, "online CPU list")) + status = base_status(boot_id, vendor, family, model) + status["online_cpus"] = cpus + + state = load_state(args.state) + if state is not None: + if state.get("boot_id") != boot_id: + unlink_synced(args.state) + state = None + elif current_values_match_target(args.device_root, state): + status.update({ + "outcome": "ready", + "reason": "already_applied", + "cpus": state["cpus"], + "rollback": {"attempted": False, "complete": None}, + }) + publish(args.status, status) + return 0 + else: + restored, errors, evidence = restore_values(args.device_root, state) + if not restored: + status.update({ + "outcome": "degraded", + "reason": "previous_state_restore_failed", + "errors": errors, + "cpus": evidence, + "rollback": {"attempted": True, "complete": False}, + }) + publish(args.status, status) + return 0 + unlink_synced(args.state) + state = None + + if (vendor, family, model) not in SUPPORTED_CPUS: + status.update({ + "outcome": "unsupported", + "reason": "cpu_not_allowlisted", + "cpus": [], + "rollback": {"attempted": False, "complete": None}, + }) + publish(args.status, status) + return 0 + + originals: list[dict] = [] + try: + for cpu in cpus: + originals.append({"cpu": cpu, "original": value_hex(read_msr(args.device_root, cpu))}) + except AuthorityError as error: + status.update({ + "outcome": "unavailable", + "reason": "msr_device_unavailable", + "errors": [str(error)], + "cpus": originals, + "rollback": {"attempted": False, "complete": None}, + }) + publish(args.status, status) + return 0 + + state = { + "schema": STATE_SCHEMA, + "boot_id": boot_id, + "register": value_hex(REGISTER), + "target_value": value_hex(TARGET_VALUE), + "cpus": originals, + } + atomic_json(args.state, state, 0o600) + + apply_error: str | None = None + evidence: list[dict] = [] + try: + for cpu in cpus: + write_msr(args.device_root, cpu, TARGET_VALUE) + for entry in originals: + cpu = entry["cpu"] + readback = read_msr(args.device_root, cpu) + evidence.append({ + "cpu": cpu, + "original": entry["original"], + "readback": value_hex(readback), + }) + if readback != TARGET_VALUE: + raise AuthorityError( + f"CPU {cpu} apply readback mismatch: expected {value_hex(TARGET_VALUE)} got {value_hex(readback)}" + ) + except AuthorityError as error: + apply_error = str(error) + + if apply_error is not None: + restored, restore_errors, restore_evidence = restore_values(args.device_root, state) + if restored: + unlink_synced(args.state) + status.update({ + "outcome": "degraded", + "reason": "apply_failed_rolled_back" if restored else "apply_failed_rollback_incomplete", + "errors": [apply_error, *restore_errors], + "cpus": restore_evidence, + "rollback": {"attempted": True, "complete": restored}, + }) + publish(args.status, status) + return 0 + + status.update({ + "outcome": "ready", + "reason": None, + "cpus": evidence, + "rollback": {"attempted": False, "complete": None}, + }) + publish(args.status, status) + return 0 + + +def restore(args: argparse.Namespace) -> int: + boot_id = read_trimmed(args.boot_id, "boot ID") + vendor: str | None = None + family: int | None = None + model: int | None = None + try: + vendor, family, model = parse_cpu_identity(args.cpuinfo) + except AuthorityError: + pass + status = base_status(boot_id, vendor, family, model) + state = load_state(args.state) + if state is None: + status.update({ + "outcome": "restored", + "reason": "state_absent", + "cpus": [], + "rollback": {"attempted": False, "complete": True}, + }) + publish(args.status, status) + return 0 + if state.get("boot_id") != boot_id: + unlink_synced(args.state) + status.update({ + "outcome": "restored", + "reason": "stale_state_discarded", + "cpus": [], + "rollback": {"attempted": False, "complete": True}, + }) + publish(args.status, status) + return 0 + restored, errors, evidence = restore_values(args.device_root, state) + if restored: + unlink_synced(args.state) + status.update({ + "outcome": "restored" if restored else "degraded", + "reason": None if restored else "restore_failed", + "errors": errors, + "cpus": evidence, + "rollback": {"attempted": True, "complete": restored}, + }) + publish(args.status, status) + return 0 + + +def parser() -> argparse.ArgumentParser: + result = argparse.ArgumentParser(description="RIGOS reversible RandomX MSR authority") + result.add_argument("command", choices=("apply", "restore")) + result.add_argument("--cpuinfo", type=Path, default=Path("/proc/cpuinfo")) + result.add_argument("--online", type=Path, default=Path("/sys/devices/system/cpu/online")) + result.add_argument("--device-root", type=Path, default=Path("/dev/cpu")) + result.add_argument("--boot-id", type=Path, default=Path("/proc/sys/kernel/random/boot_id")) + result.add_argument("--status", type=Path, default=Path("/run/rigos/randomx-msr-status.json")) + result.add_argument("--state", type=Path, default=Path("/run/rigos/randomx-msr-state.json")) + result.add_argument("--lock", type=Path, default=Path("/run/rigos/.randomx-msr.lock")) + return result + + +def main() -> int: + args = parser().parse_args() + args.lock.parent.mkdir(parents=True, exist_ok=True) + with args.lock.open("a+b") as lock_handle: + os.chmod(args.lock, 0o600) + fcntl.flock(lock_handle.fileno(), fcntl.LOCK_EX) + if args.command == "apply": + return apply(args) + return restore(args) + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (AuthorityError, OSError, ValueError) as error: + print(f"rigos-randomx-msr: {error}", file=sys.stderr) + raise SystemExit(2) diff --git a/build/usb/includes.chroot/usr/lib/rigos/rigos-runtime-publish b/build/usb/includes.chroot/usr/lib/rigos/rigos-runtime-publish index c0aa5740..7e3611bc 100644 --- a/build/usb/includes.chroot/usr/lib/rigos/rigos-runtime-publish +++ b/build/usb/includes.chroot/usr/lib/rigos/rigos-runtime-publish @@ -24,7 +24,9 @@ cleanup() { } trap cleanup EXIT HUP INT TERM -RIGOS_RUNTIME_PATH="$stage" "$renderer" +RIGOS_RUNTIME_PATH="$stage" \ +RIGOS_XMRIG_API_TOKEN_PATH="$runtime/xmrig-api-token" \ + "$renderer" "$jq_bin" -e ' .schema == "rigos.runtime-config-status/v1" and diff --git a/build/usb/includes.chroot/usr/lib/rigos/rigos-runtime-render b/build/usb/includes.chroot/usr/lib/rigos/rigos-runtime-render index 3effedd9..c1f1bdce 100644 --- a/build/usb/includes.chroot/usr/lib/rigos/rigos-runtime-render +++ b/build/usb/includes.chroot/usr/lib/rigos/rigos-runtime-render @@ -4,6 +4,9 @@ import grp import hashlib import json import os +import re +import secrets +import stat import tempfile from pathlib import Path @@ -12,7 +15,12 @@ RUNTIME = Path(os.environ.get("RIGOS_RUNTIME_PATH", "/run/rigos")) PRIVATE_CONFIG = RUNTIME / "xmrig.json" PUBLIC_CONFIG = RUNTIME / "xmrig-public.json" STATUS = RUNTIME / "runtime-config-status.json" +API_TOKEN = Path(os.environ.get("RIGOS_XMRIG_API_TOKEN_PATH", str(RUNTIME / "xmrig-api-token"))) +API_HOST = "127.0.0.1" +API_PORT = 18080 MAX_JSON_BYTES = 2 * 1024 * 1024 +TOKEN_RE = re.compile(r"^[A-Za-z0-9_-]{32,128}$") +SKIP_CHOWN = os.environ.get("RIGOS_RENDER_SKIP_CHOWN") == "1" def read_object(path: Path) -> dict: @@ -53,6 +61,57 @@ def atomic_json(path: Path, value: dict, mode: int, gid: int | None = None) -> N temporary.unlink(missing_ok=True) +def token_owner_uid() -> int: + return os.geteuid() if SKIP_CHOWN else 0 + + +def validate_token_descriptor(descriptor: int) -> str: + observed = os.fstat(descriptor) + if not stat.S_ISREG(observed.st_mode): + raise RuntimeError("XMRig API token is not a regular file") + if observed.st_uid != token_owner_uid(): + raise RuntimeError("XMRig API token owner is unsafe") + if stat.S_IMODE(observed.st_mode) != 0o600: + raise RuntimeError("XMRig API token mode is unsafe") + if observed.st_size > 256: + raise RuntimeError("XMRig API token is oversized") + os.lseek(descriptor, 0, os.SEEK_SET) + raw = os.read(descriptor, 257) + try: + token = raw.decode("ascii").strip() + except UnicodeDecodeError as error: + raise RuntimeError("XMRig API token is not ASCII") from error + if not TOKEN_RE.fullmatch(token): + raise RuntimeError("XMRig API token format is invalid") + return token + + +def load_or_create_api_token() -> str: + API_TOKEN.parent.mkdir(mode=0o755, parents=True, exist_ok=True) + nofollow = getattr(os, "O_NOFOLLOW", 0) + flags = os.O_RDONLY | os.O_CLOEXEC | nofollow + try: + descriptor = os.open(API_TOKEN, flags) + except FileNotFoundError: + token = secrets.token_urlsafe(48) + if not TOKEN_RE.fullmatch(token): + raise RuntimeError("generated XMRig API token format is invalid") + create_flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_CLOEXEC | nofollow + descriptor = os.open(API_TOKEN, create_flags, 0o600) + try: + os.write(descriptor, (token + "\n").encode("ascii")) + os.fsync(descriptor) + finally: + os.close(descriptor) + API_TOKEN.chmod(0o600) + fsync_directory(API_TOKEN.parent) + descriptor = os.open(API_TOKEN, flags) + try: + return validate_token_descriptor(descriptor) + finally: + os.close(descriptor) + + def exact_profile_name(algorithm: str) -> str: if algorithm in ("rx", "rx/0"): return "rx" @@ -103,6 +162,15 @@ def render() -> tuple[dict, dict, dict]: elif threads != "auto": raise RuntimeError("threads must be auto or an integer") + api_token = load_or_create_api_token() + runtime["http"] = { + "enabled": True, + "host": API_HOST, + "port": API_PORT, + "access-token": api_token, + "restricted": True, + } + public = copy.deepcopy(runtime) for pool in public.get("pools", []): if isinstance(pool, dict): @@ -142,6 +210,13 @@ def render() -> tuple[dict, dict, dict]: (json.dumps(runtime, sort_keys=True) + "\n").encode("utf-8") ).hexdigest(), "identity_redacted_public_view": True, + "http_api": { + "enabled": True, + "host": API_HOST, + "port": API_PORT, + "restricted": True, + "token_path": str(API_TOKEN), + }, } return runtime, public, status @@ -149,7 +224,7 @@ def render() -> tuple[dict, dict, dict]: def main() -> int: runtime, public, status = render() gid = None - if os.environ.get("RIGOS_RENDER_SKIP_CHOWN") != "1": + if not SKIP_CHOWN: gid = grp.getgrnam("rigos").gr_gid atomic_json(PRIVATE_CONFIG, runtime, 0o640, gid) atomic_json(PUBLIC_CONFIG, public, 0o644) diff --git a/build/usb/includes.chroot/usr/lib/rigos/rigos-ssh-hostkeys b/build/usb/includes.chroot/usr/lib/rigos/rigos-ssh-hostkeys new file mode 100644 index 00000000..64bbbe43 --- /dev/null +++ b/build/usb/includes.chroot/usr/lib/rigos/rigos-ssh-hostkeys @@ -0,0 +1,320 @@ +#!/usr/bin/env python3 +import datetime +import grp +import hashlib +import json +import os +import shutil +import stat +import subprocess +import sys +import tempfile +from pathlib import Path + +STATE = Path("/var/lib/rigos") +SYSTEM = STATE / "system" +KEYS = SYSTEM / "ssh-hostkeys" +PRIVATE_KEY = KEYS / "ssh_host_ed25519_key" +PUBLIC_KEY = KEYS / "ssh_host_ed25519_key.pub" +MANIFEST = KEYS / "manifest.json" +RUNTIME = Path("/run/rigos") +STATUS = RUNTIME / "ssh-hostkeys-status.json" +STATE_STATUS = RUNTIME / "state-status.json" +BOOT_ID = Path("/proc/sys/kernel/random/boot_id") +SSH_KEYGEN = "/usr/bin/ssh-keygen" +FINDMNT = "/usr/bin/findmnt" + + +class AuthorityError(RuntimeError): + pass + + +def read_json(path: Path) -> dict: + try: + raw = path.read_bytes() + if len(raw) > 64 * 1024: + return {} + value = json.loads(raw) + return value if isinstance(value, dict) else {} + except (OSError, json.JSONDecodeError): + return {} + + +def fsync_directory(path: Path) -> None: + descriptor = os.open(path, os.O_RDONLY | os.O_DIRECTORY) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + + +def write_atomic_json(path: Path, value: dict, mode: int = 0o644) -> None: + path.parent.mkdir(mode=0o755, parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + dir=path.parent, + prefix=f".{path.name}-", + delete=False, + ) as stream: + json.dump(value, stream, indent=2, sort_keys=True) + stream.write("\n") + stream.flush() + os.fsync(stream.fileno()) + temporary = Path(stream.name) + os.chown(temporary, 0, 0) + temporary.chmod(mode) + os.replace(temporary, path) + fsync_directory(path.parent) + + +def current_boot_id() -> str: + try: + value = BOOT_ID.read_text(encoding="ascii").strip() + except OSError as error: + raise AuthorityError(f"boot id unavailable: {error}") from error + if not value: + raise AuthorityError("boot id is empty") + return value + + +def validate_state_ready(boot_id: str) -> None: + status = read_json(STATE_STATUS) + if ( + status.get("schema") != "rigos.state-status/v1" + or status.get("boot_id") != boot_id + or status.get("outcome") != "ready" + ): + raise AuthorityError("current persistent state readiness is unavailable") + + observed = subprocess.run( + [FINDMNT, "--json", "--target", str(STATE), "--output", "TARGET,FSTYPE,OPTIONS"], + check=False, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + if observed.returncode != 0: + raise AuthorityError("persistent state mount query failed") + try: + filesystems = json.loads(observed.stdout).get("filesystems", []) + except json.JSONDecodeError as error: + raise AuthorityError("persistent state mount query returned invalid JSON") from error + if len(filesystems) != 1: + raise AuthorityError("persistent state mount is ambiguous") + filesystem = filesystems[0] + options = set(str(filesystem.get("options") or "").split(",")) + if ( + filesystem.get("target") != str(STATE) + or filesystem.get("fstype") != "ext4" + or "rw" not in options + ): + raise AuthorityError("persistent state is not the expected read-write ext4 mount") + + +def secure_state_root() -> None: + try: + state = STATE.lstat() + except OSError as error: + raise AuthorityError(f"persistent state root unavailable: {error}") from error + if stat.S_ISLNK(state.st_mode) or not stat.S_ISDIR(state.st_mode): + raise AuthorityError("persistent state root is not a real directory") + try: + rigos_gid = grp.getgrnam("rigos").gr_gid + os.chown(STATE, 0, rigos_gid) + STATE.chmod(0o750) + except OSError as error: + raise AuthorityError(f"persistent state root could not be secured: {error}") from error + + +def ensure_secure_directory(path: Path, mode: int) -> None: + if not path.exists(): + path.mkdir(mode=mode) + os.chown(path, 0, 0) + path.chmod(mode) + observed = path.lstat() + if stat.S_ISLNK(observed.st_mode) or not stat.S_ISDIR(observed.st_mode): + raise AuthorityError(f"{path} is not a real directory") + if observed.st_uid != 0 or observed.st_gid != 0: + raise AuthorityError(f"{path} is not owned by root:root") + if stat.S_IMODE(observed.st_mode) != mode: + raise AuthorityError(f"{path} has unsafe mode") + + +def validate_regular_file(path: Path, mode: int) -> None: + try: + observed = path.lstat() + except OSError as error: + raise AuthorityError(f"required host identity file is unavailable: {path.name}") from error + if stat.S_ISLNK(observed.st_mode) or not stat.S_ISREG(observed.st_mode): + raise AuthorityError(f"host identity file is not regular: {path.name}") + if observed.st_uid != 0 or observed.st_gid != 0: + raise AuthorityError(f"host identity file owner is unsafe: {path.name}") + if stat.S_IMODE(observed.st_mode) != mode: + raise AuthorityError(f"host identity file mode is unsafe: {path.name}") + + +def command_output(arguments: list[str]) -> str: + result = subprocess.run( + arguments, + check=False, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=30, + ) + if result.returncode != 0: + raise AuthorityError(f"host identity command failed: {Path(arguments[0]).name}") + return result.stdout.strip() + + +def key_fingerprint(public_key: Path) -> str: + output = command_output([SSH_KEYGEN, "-E", "sha256", "-lf", str(public_key)]) + fields = output.split() + if len(fields) < 2 or not fields[1].startswith("SHA256:"): + raise AuthorityError("host identity fingerprint is invalid") + return fields[1] + + +def validate_keyset() -> str: + ensure_secure_directory(KEYS, 0o700) + validate_regular_file(PRIVATE_KEY, 0o600) + validate_regular_file(PUBLIC_KEY, 0o644) + validate_regular_file(MANIFEST, 0o644) + + public_fields = PUBLIC_KEY.read_text(encoding="ascii").strip().split() + derived_fields = command_output([SSH_KEYGEN, "-y", "-f", str(PRIVATE_KEY)]).split() + if len(public_fields) < 2 or derived_fields[:2] != public_fields[:2]: + raise AuthorityError("persistent SSH public and private keys do not match") + + fingerprint = key_fingerprint(PUBLIC_KEY) + public_sha256 = hashlib.sha256(PUBLIC_KEY.read_bytes()).hexdigest() + manifest = read_json(MANIFEST) + if ( + manifest.get("schema") != "rigos.ssh-hostkeys/v1" + or manifest.get("algorithm") != "ssh-ed25519" + or manifest.get("fingerprint") != fingerprint + or manifest.get("public_key_sha256") != public_sha256 + or not isinstance(manifest.get("created_boot_id"), str) + or not manifest.get("created_boot_id") + ): + raise AuthorityError("persistent SSH host identity manifest is invalid") + return fingerprint + + +def remove_stale_staging() -> None: + for candidate in SYSTEM.glob(".ssh-hostkeys.tmp-*"): + observed = candidate.lstat() + if stat.S_ISLNK(observed.st_mode) or not stat.S_ISDIR(observed.st_mode): + raise AuthorityError("unsafe SSH host identity staging path exists") + if observed.st_uid != 0 or observed.st_gid != 0: + raise AuthorityError("untrusted SSH host identity staging directory exists") + shutil.rmtree(candidate) + fsync_directory(SYSTEM) + + +def generate_keyset(boot_id: str) -> str: + if KEYS.exists() or KEYS.is_symlink(): + raise AuthorityError("persistent SSH host identity exists without a valid manifest") + remove_stale_staging() + + temporary = Path(tempfile.mkdtemp(prefix=".ssh-hostkeys.tmp-", dir=SYSTEM)) + try: + os.chown(temporary, 0, 0) + temporary.chmod(0o700) + private_key = temporary / PRIVATE_KEY.name + public_key = temporary / PUBLIC_KEY.name + manifest_path = temporary / MANIFEST.name + + result = subprocess.run( + [ + SSH_KEYGEN, + "-q", + "-t", + "ed25519", + "-N", + "", + "-C", + "rigos-host", + "-f", + str(private_key), + ], + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + text=True, + timeout=30, + ) + if result.returncode != 0: + raise AuthorityError("persistent SSH host identity generation failed") + + os.chown(private_key, 0, 0) + os.chown(public_key, 0, 0) + private_key.chmod(0o600) + public_key.chmod(0o644) + fingerprint = key_fingerprint(public_key) + manifest = { + "schema": "rigos.ssh-hostkeys/v1", + "algorithm": "ssh-ed25519", + "created_at": datetime.datetime.now(datetime.timezone.utc).isoformat(), + "created_boot_id": boot_id, + "fingerprint": fingerprint, + "public_key_sha256": hashlib.sha256(public_key.read_bytes()).hexdigest(), + } + write_atomic_json(manifest_path, manifest, 0o644) + + for path in (private_key, public_key, manifest_path): + descriptor = os.open(path, os.O_RDONLY) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + fsync_directory(temporary) + os.rename(temporary, KEYS) + fsync_directory(SYSTEM) + finally: + if temporary.exists(): + shutil.rmtree(temporary) + return validate_keyset() + + +def write_status(boot_id: str, outcome: str, **extra: object) -> None: + value = { + "schema": "rigos.ssh-hostkeys-status/v1", + "boot_id": boot_id, + "outcome": outcome, + "persistent_path": str(PRIVATE_KEY), + } + value.update(extra) + try: + write_atomic_json(STATUS, value) + except OSError: + pass + + +def main() -> int: + boot_id = "unavailable" + try: + boot_id = current_boot_id() + validate_state_ready(boot_id) + secure_state_root() + ensure_secure_directory(SYSTEM, 0o700) + reused = KEYS.exists() + fingerprint = validate_keyset() if reused else generate_keyset(boot_id) + write_status( + boot_id, + "ready", + algorithm="ssh-ed25519", + fingerprint=fingerprint, + reused=reused, + ) + print(f"RIGOS_SSH_HOSTKEYS=READY fingerprint={fingerprint} reused={str(reused).lower()}") + return 0 + except (AuthorityError, OSError, subprocess.SubprocessError) as error: + write_status(boot_id, "error", reason=str(error)) + print(f"rigos-ssh-hostkeys: {error}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/build/usb/package-lists/rigos.list.chroot b/build/usb/package-lists/rigos.list.chroot index fc7df3aa..0750eda2 100644 --- a/build/usb/package-lists/rigos.list.chroot +++ b/build/usb/package-lists/rigos.list.chroot @@ -5,6 +5,7 @@ e2fsprogs fdisk gdisk jq +kmod live-boot live-config linux-image-amd64 diff --git a/crates/rigos-config/tests/firstboot_tty.rs b/crates/rigos-config/tests/firstboot_tty.rs index 3fe2e886..414101a1 100644 --- a/crates/rigos-config/tests/firstboot_tty.rs +++ b/crates/rigos-config/tests/firstboot_tty.rs @@ -1,3 +1,4 @@ +use std::collections::BTreeSet; use std::fs; use std::path::PathBuf; @@ -9,12 +10,19 @@ fn unit(name: &str) -> String { .unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display())) } +fn directive_tokens<'a>(unit: &'a str, prefix: &str) -> BTreeSet<&'a str> { + unit.lines() + .filter_map(|line| line.strip_prefix(prefix)) + .flat_map(str::split_whitespace) + .collect() +} + #[test] fn firstboot_releases_tty1_to_getty_after_exit() { let unit = unit("rigos-firstboot.service"); assert!( - unit.lines().any(|line| line == "Before=getty@tty1.service"), + directive_tokens(&unit, "Before=").contains("getty@tty1.service"), "firstboot must finish before tty1 getty starts" ); assert!( @@ -36,12 +44,14 @@ fn firstboot_releases_tty1_to_getty_after_exit() { #[test] fn recovery_access_does_not_hang_up_the_following_firstboot_session() { let unit = unit("rigos-recovery-access.service"); + let before = directive_tokens(&unit, "Before="); - assert!( - unit.lines() - .any(|line| line.contains("Before=rigos-state-ready.service rigos-firstboot.service")), - "recovery access must complete before firstboot" - ); + for required in ["rigos-state-ready.service", "rigos-firstboot.service"] { + assert!( + before.contains(required), + "recovery access must complete before {required}" + ); + } assert!( !unit.lines().any(|line| line == "TTYVHangup=yes"), "recovery access must not hang up tty1 before firstboot starts" @@ -51,26 +61,41 @@ fn recovery_access_does_not_hang_up_the_following_firstboot_session() { #[test] fn state_ready_stays_out_of_the_local_fs_transaction() { let unit = unit("rigos-state-ready.service"); + let after = directive_tokens(&unit, "After="); + let requires = directive_tokens(&unit, "Requires="); + let before = directive_tokens(&unit, "Before="); assert!(!unit.lines().any(|line| line == "DefaultDependencies=no")); assert!( - !unit - .lines() - .any(|line| line.contains("Before=local-fs.target")) - ); - assert!( - unit.lines() - .any(|line| line == "WantedBy=multi-user.target") + !before.contains("local-fs.target"), + "state readiness must stay out of the local-fs transaction" ); assert!( - unit.lines() - .any(|line| line == "After=rigos-state.service rigos-recovery-access.service") + directive_tokens(&unit, "WantedBy=").contains("multi-user.target"), + "state readiness must be installed under multi-user.target" ); + + for required in ["rigos-state.service", "rigos-recovery-access.service"] { + assert!( + after.contains(required), + "state readiness must start after {required}" + ); + } assert!( - unit.lines() - .any(|line| line == "Requires=rigos-state.service") + requires.contains("rigos-state.service"), + "state readiness must require persistent state" ); - assert!(unit.lines().any(|line| { - line == "Before=rigos-profile-apply.service rigos-firstboot.service rigos-hugepages.service rigos-miner.service" - })); + + for required in [ + "rigos-ssh-hostkeys.service", + "rigos-profile-apply.service", + "rigos-firstboot.service", + "rigos-hugepages.service", + "rigos-miner.service", + ] { + assert!( + before.contains(required), + "state readiness must complete before {required}" + ); + } } diff --git a/crates/rigos-config/tests/hive_exit_runtime_publish.rs b/crates/rigos-config/tests/hive_exit_runtime_publish.rs index 55462e35..55b7dec2 100644 --- a/crates/rigos-config/tests/hive_exit_runtime_publish.rs +++ b/crates/rigos-config/tests/hive_exit_runtime_publish.rs @@ -1,3 +1,5 @@ +#![cfg(unix)] + use serde_json::Value; use std::fs; use std::os::unix::fs::{PermissionsExt, symlink}; @@ -75,6 +77,9 @@ fn staged_runtime_publication_is_allowlisted_atomic_and_fail_closed() { }], "http": { "enabled": false, + "host": "0.0.0.0", + "port": 9999, + "restricted": false, "access-token": "TOKEN_SENTINEL", "future_http": "HTTP_SENTINEL" } @@ -112,10 +117,26 @@ fn staged_runtime_publication_is_allowlisted_atomic_and_fail_closed() { assert_eq!(private["future_top"], "TOP_SENTINEL"); assert_eq!(private["cpu"]["future_cpu"], "CPU_SENTINEL"); assert_eq!(private["pools"][0]["future_pool"], "POOL_SENTINEL"); - assert_eq!(private["http"]["future_http"], "HTTP_SENTINEL"); assert_eq!(private["cpu"]["max-threads-hint"], 100); assert_eq!(private["cpu"]["rx"], serde_json::json!([-1, -1])); + let private_http = private["http"].as_object().expect("private HTTP authority"); + let private_http_keys = private_http.keys().cloned().collect::>(); + assert_eq!( + private_http_keys, + vec!["access-token", "enabled", "host", "port", "restricted"] + ); + assert_eq!(private_http["enabled"], true); + assert_eq!(private_http["host"], "127.0.0.1"); + assert_eq!(private_http["port"], 18080); + assert_eq!(private_http["restricted"], true); + let api_token = private_http["access-token"] + .as_str() + .expect("private API token"); + assert!(api_token.len() >= 32); + assert_ne!(api_token, "TOKEN_SENTINEL"); + assert!(private_http.get("future_http").is_none()); + let public = read_json(&runtime.join("xmrig-public.json")); let public_keys = public .as_object() @@ -142,6 +163,11 @@ fn staged_runtime_publication_is_allowlisted_atomic_and_fail_closed() { assert_eq!(public["randomx"]["huge-pages"].as_bool(), Some(true)); assert_eq!(public["pools"][0]["url"], "pool.test:1"); assert_eq!(public["rigos-public-view"]["construction"], "allowlist"); + assert_eq!(public["http"]["enabled"], true); + assert_eq!(public["http"]["host"], "127.0.0.1"); + assert_eq!(public["http"]["port"], 18080); + assert_eq!(public["http"]["restricted"], true); + assert!(public["http"].get("access-token").is_none()); let public_text = serde_json::to_string(&public).unwrap(); for sentinel in [ "TOP_SENTINEL", diff --git a/crates/rigos-config/tests/hive_exit_shell_syntax.rs b/crates/rigos-config/tests/hive_exit_shell_syntax.rs index f8087c5e..dff771c4 100644 --- a/crates/rigos-config/tests/hive_exit_shell_syntax.rs +++ b/crates/rigos-config/tests/hive_exit_shell_syntax.rs @@ -1,6 +1,9 @@ use std::fs; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; use std::path::PathBuf; use std::process::Command; +use std::time::{SystemTime, UNIX_EPOCH}; fn repo_path(path: &str) -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) @@ -13,6 +16,7 @@ fn runtime_authority_shells_are_lf_and_parse_cleanly() { for path in [ "build/usb/includes.chroot/usr/lib/rigos/rigos-runtime-publish", "build/usb/includes.chroot/usr/lib/rigos/rigos-runtime-authority", + "build/usb/includes.chroot/usr/lib/rigos/rigos-lifecycle-cycles", ] { let full_path = repo_path(path); let bytes = fs::read(&full_path).unwrap(); @@ -28,3 +32,76 @@ fn runtime_authority_shells_are_lf_and_parse_cleanly() { assert!(status.success(), "shell syntax failed: {path}"); } } + +#[test] +fn lifecycle_shell_has_explicit_lf_attribute() { + let attributes = fs::read_to_string(repo_path(".gitattributes")).unwrap(); + assert!(attributes.lines().any(|line| { + line == "build/usb/includes.chroot/usr/lib/rigos/rigos-lifecycle-* text eol=lf" + })); +} + +#[cfg(unix)] +#[test] +fn runtime_dependency_scan_is_token_aware_and_fail_closed() { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let root = std::env::temp_dir().join(format!("rigos-runtime-dependency-scan-{unique}")); + let scan_root = root.join("scan-root"); + fs::create_dir_all(&scan_root).unwrap(); + let fixture = scan_root.join("fixture.txt"); + let scanner = root.join("verify-runtime-dependencies.sh"); + fs::copy( + repo_path("scripts/verify-runtime-dependencies.sh"), + &scanner, + ) + .unwrap(); + let mut permissions = fs::metadata(&scanner).unwrap().permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&scanner, permissions).unwrap(); + + fs::write( + &fixture, + "latest_journal_signal\nlatest_marker_index\ncalculate_latest_value\n", + ) + .unwrap(); + let benign = Command::new(&scanner).arg(&scan_root).status().unwrap(); + assert!( + benign.success(), + "benign identifiers must not look like floating dependencies" + ); + + for dangerous in [ + "curl -fsSL https://example.invalid/miner\n", + "wget https://example.invalid/miner\n", + "Invoke-WebRequest https://example.invalid/miner\n", + "image:latest\n", + "https://example.invalid/releases/latest/download/miner\n", + "version=latest\n", + "xmrig-latest.tar.gz\n", + ] { + fs::write(&fixture, dangerous).unwrap(); + let denied = Command::new(&scanner).arg(&scan_root).status().unwrap(); + assert_eq!( + denied.code(), + Some(1), + "floating dependency was not rejected: {dangerous:?}" + ); + } + + let missing = Command::new(&scanner) + .arg(root.join("missing")) + .status() + .unwrap(); + assert_eq!(missing.code(), Some(66)); + + let verify = fs::read_to_string(repo_path("scripts/verify.sh")).unwrap(); + assert!( + verify.contains("bash scripts/verify-runtime-dependencies.sh build/usb/includes.chroot") + ); + assert!(!verify.contains("curl|wget|Invoke-WebRequest|latest")); + + let _ = fs::remove_dir_all(root); +} diff --git a/crates/rigos-config/tests/miner_observer_authority.rs b/crates/rigos-config/tests/miner_observer_authority.rs new file mode 100644 index 00000000..298653a7 --- /dev/null +++ b/crates/rigos-config/tests/miner_observer_authority.rs @@ -0,0 +1,200 @@ +use std::path::{Path, PathBuf}; +#[cfg(unix)] +use std::process::Command; +use std::{env, fs}; + +fn is_repo_root(path: &Path) -> bool { + path.join("Cargo.toml").is_file() + && path.join("crates/rigos-config/Cargo.toml").is_file() + && path.join("scripts/verify.sh").is_file() +} + +fn repo_root() -> PathBuf { + let mut starts = Vec::new(); + + if let Ok(current) = env::current_dir() { + starts.push(current); + } + if let Ok(executable) = env::current_exe() { + if let Some(parent) = executable.parent() { + starts.push(parent.to_path_buf()); + } + } + + for start in starts { + let mut candidate = start; + loop { + if is_repo_root(&candidate) { + return candidate; + } + if !candidate.pop() { + break; + } + } + } + + panic!("unable to locate the RIGOS repository root at runtime"); +} + +fn repo_path(path: &str) -> PathBuf { + repo_root().join(path) +} + +#[cfg(unix)] +fn run_python(script: &str) { + let python = env::var("RIGOS_PYTHON").unwrap_or_else(|_| "python3".to_string()); + let status = Command::new(&python) + .arg(repo_path(script)) + .current_dir(repo_root()) + .status() + .unwrap_or_else(|error| panic!("failed to execute {script} with {python}: {error}")); + assert!( + status.success(), + "observer source regression failed: {script}" + ); +} + +#[cfg(unix)] +#[test] +fn authenticated_miner_observer_behavioral_source_gate() { + for script in [ + "scripts/test-miner-health-api.py", + "scripts/test-miner-health-api-authority-errors.py", + "scripts/test-miner-health-api-schema.py", + "scripts/test-miner-health-connection-state.py", + "scripts/test-miner-health-journal-fallback.py", + "scripts/test-runtime-token-publication.py", + ] { + run_python(script); + } +} + +#[test] +fn observer_authority_is_wired_into_build_and_exact_image_gates() { + let entrypoint = fs::read_to_string(repo_path("scripts/build-usb-image-entrypoint.sh")) + .expect("read performance image entrypoint"); + let image_verifier = fs::read_to_string(repo_path("scripts/verify-miner-observer-image.sh")) + .expect("read observer image verifier"); + let observer = fs::read_to_string(repo_path( + "build/usb/includes.chroot/usr/lib/rigos/rigos-miner-health", + )) + .expect("read miner observer"); + let renderer = fs::read_to_string(repo_path( + "build/usb/includes.chroot/usr/lib/rigos/rigos-runtime-render", + )) + .expect("read runtime renderer"); + let publisher = fs::read_to_string(repo_path( + "build/usb/includes.chroot/usr/lib/rigos/rigos-runtime-publish", + )) + .expect("read runtime publisher"); + + for script in [ + "test-miner-health-api.py", + "test-miner-health-api-authority-errors.py", + "test-miner-health-api-schema.py", + "test-miner-health-connection-state.py", + "test-miner-health-journal-fallback.py", + "test-runtime-token-publication.py", + ] { + assert!( + entrypoint.contains(script), + "performance image entrypoint does not run {script}" + ); + } + assert!(entrypoint.contains("--test miner_observer_authority")); + + for contract in [ + "RIGOS_XMRIG_API_TOKEN_PATH", + "hashrate_10s", + "nonnegative_integer", + "current_hashrate_unavailable", + "no_current_hashrate_from_api", + "latest_journal_signal", + "api_error not in (None, \"api_unavailable\")", + ] { + assert!( + observer.contains(contract), + "observer contract missing: {contract}" + ); + } + + assert!(renderer.contains("secrets.token_urlsafe(48)")); + assert!(renderer.contains("127.0.0.1")); + assert!(renderer.contains("restricted\": True")); + assert!(publisher.contains("RIGOS_XMRIG_API_TOKEN_PATH=\"$runtime/xmrig-api-token\"")); + + for contract in [ + "extracted observer misclassifies disconnected historical hashrate", + "extracted observer trusts historical hashrate as current", + "extracted observer trusts stale journal ready evidence", + "extracted observer hides API authority failure", + "extracted observer truncates fractional counters", + ] { + assert!( + image_verifier.contains(contract), + "exact-image behavioral contract missing: {contract}" + ); + } +} + +#[test] +fn unix_only_integrations_are_explicitly_platform_gated() { + for path in [ + "crates/rigos-config/tests/miner_stability.rs", + "crates/rigos-config/tests/hive_exit_runtime_publish.rs", + "crates/rigos-config/tests/randomx_msr_authority.rs", + ] { + let source = fs::read_to_string(repo_path(path)) + .unwrap_or_else(|error| panic!("read Unix integration {path}: {error}")); + assert!( + source.starts_with("#![cfg(unix)]\n"), + "Unix integration is not explicitly platform gated: {path}" + ); + } + + let recovery = fs::read_to_string(repo_path("crates/rigos-config/tests/recovery_access.rs")) + .expect("read recovery access integration"); + let unix_scope = recovery + .find("#[cfg(unix)]\n {") + .expect("recovery executable-mode assertion is not Unix scoped"); + let permissions_import = recovery + .find("use std::os::unix::fs::PermissionsExt;") + .expect("recovery Unix permissions import is missing"); + let mode_binding = recovery + .find("let mode = fs::metadata(authority).unwrap().permissions();") + .expect("recovery Unix mode binding is missing"); + assert!(unix_scope < permissions_import && permissions_import < mode_binding); +} + +#[test] +fn observer_test_files_are_regular_repository_files() { + for path in [ + "scripts/test-miner-health-api.py", + "scripts/test-miner-health-api-authority-errors.py", + "scripts/test-miner-health-api-schema.py", + "scripts/test-miner-health-connection-state.py", + "scripts/test-miner-health-journal-fallback.py", + "scripts/test-runtime-token-publication.py", + ] { + let metadata = fs::metadata(repo_path(path)) + .unwrap_or_else(|error| panic!("observer test file is unavailable: {path}: {error}")); + assert!( + metadata.is_file(), + "observer test path is not a file: {path}" + ); + } + + assert!(repo_path("scripts/verify-miner-observer-image.sh").is_file()); +} + +#[test] +fn repository_root_is_resolved_at_runtime() { + let root = repo_root(); + assert!(is_repo_root(&root)); + + let source = + fs::read_to_string(root.join("crates/rigos-config/tests/miner_observer_authority.rs")) + .expect("read observer authority source"); + let compile_time_manifest = ["CARGO", "_MANIFEST_DIR"].concat(); + assert!(!source.contains(&compile_time_manifest)); +} diff --git a/crates/rigos-config/tests/miner_stability.rs b/crates/rigos-config/tests/miner_stability.rs index 37a6405c..9ac3c585 100644 --- a/crates/rigos-config/tests/miner_stability.rs +++ b/crates/rigos-config/tests/miner_stability.rs @@ -1,10 +1,18 @@ +#![cfg(unix)] + use serde_json::Value; use std::fs; +use std::io::{Read, Write}; +use std::net::TcpListener; use std::os::unix::fs::{PermissionsExt, symlink}; use std::path::{Path, PathBuf}; use std::process::Command; +use std::thread; +use std::time::Duration; use uuid::Uuid; +const API_TOKEN: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + fn repo_path(path: &str) -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("../..") @@ -31,13 +39,64 @@ fn write_runtime_status(path: &Path, revision: &str) { .unwrap(); } +fn api_port_and_server(summary: Option) -> (u16, Option>) { + let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let port = listener.local_addr().unwrap().port(); + + let Some(summary) = summary else { + drop(listener); + return (port, None); + }; + + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + stream + .set_write_timeout(Some(Duration::from_secs(5))) + .unwrap(); + + let mut request = Vec::new(); + let mut buffer = [0_u8; 1024]; + loop { + let count = stream.read(&mut buffer).unwrap(); + if count == 0 { + break; + } + request.extend_from_slice(&buffer[..count]); + if request.windows(4).any(|window| window == b"\r\n\r\n") { + break; + } + assert!(request.len() <= 8192, "observer HTTP request is oversized"); + } + + let request = String::from_utf8(request).unwrap(); + assert!(request.starts_with("GET /2/summary HTTP/1.1\r\n")); + assert!(request.contains(&format!("Authorization: Bearer {API_TOKEN}\r\n"))); + + let body = serde_json::to_vec(&summary).unwrap(); + let headers = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + stream.write_all(headers.as_bytes()).unwrap(); + stream.write_all(&body).unwrap(); + stream.flush().unwrap(); + }); + + (port, Some(server)) +} + fn run_observer( root: &Path, systemctl: &Path, journalctl: &Path, systemctl_fixture: &Path, journal_fixture: &Path, + api_summary: Option, ) -> Value { + let (api_port, server) = api_port_and_server(api_summary); let status = Command::new("python3") .arg(repo_path( "build/usb/includes.chroot/usr/lib/rigos/rigos-miner-health", @@ -50,12 +109,83 @@ fn run_observer( .env("RIGOS_JOURNALCTL", journalctl) .env("RIGOS_SYSTEMCTL_FIXTURE", systemctl_fixture) .env("RIGOS_JOURNAL_FIXTURE", journal_fixture) + .env( + "RIGOS_XMRIG_API_TOKEN_PATH", + root.join("run/xmrig-api-token"), + ) + .env("RIGOS_XMRIG_API_HOST", "127.0.0.1") + .env("RIGOS_XMRIG_API_PORT", api_port.to_string()) + .env("RIGOS_XMRIG_API_TIMEOUT_SECONDS", "0.5") + .env("RIGOS_MINER_HEALTH_TEST_MODE", "1") .status() .unwrap(); + + if let Some(server) = server { + server.join().unwrap(); + } assert!(status.success()); serde_json::from_slice(&fs::read(root.join("run/miner-health-status.json")).unwrap()).unwrap() } +fn ready_summary() -> Value { + serde_json::json!({ + "uptime": 600, + "algo": "rx/0", + "hashrate": { + "total": [340.0, 341.0, 339.0], + "highest": 342.0 + }, + "connection": { + "pool": "pool.test:1", + "ip": "127.0.0.1", + "uptime": 590, + "accepted": 7, + "rejected": 0, + "failures": 0, + "ping": 20 + }, + "hugepages": [4, 4] + }) +} + +fn disconnected_summary() -> Value { + serde_json::json!({ + "uptime": 600, + "algo": "rx/0", + "hashrate": { + "total": [0.0, 341.0, 339.0], + "highest": 342.0 + }, + "connection": { + "accepted": 7, + "rejected": 0, + "failures": 1 + }, + "hugepages": [4, 4] + }) +} + +fn missing_current_hashrate_summary() -> Value { + serde_json::json!({ + "uptime": 600, + "algo": "rx/0", + "hashrate": { + "total": [null, 341.0, 339.0], + "highest": 342.0 + }, + "connection": { + "pool": "pool.test:1", + "ip": "127.0.0.1", + "uptime": 590, + "accepted": 7, + "rejected": 0, + "failures": 0, + "ping": 20 + }, + "hugepages": [4, 4] + }) +} + #[test] fn miner_health_distinguishes_ready_external_wait_degraded_blocked_and_unknown() { let root = std::env::temp_dir().join(format!("rigos-miner-health-{}", Uuid::new_v4())); @@ -66,6 +196,12 @@ fn miner_health_distinguishes_ready_external_wait_degraded_blocked_and_unknown() fs::write(root.join("boot-id"), "boot-test\n").unwrap(); fs::write(root.join("proc/uptime"), "1000.0 0.0\n").unwrap(); fs::write(root.join("proc/123/stat"), "123 (xmrig) S\n").unwrap(); + fs::write(root.join("run/xmrig-api-token"), format!("{API_TOKEN}\n")).unwrap(); + fs::set_permissions( + root.join("run/xmrig-api-token"), + fs::Permissions::from_mode(0o600), + ) + .unwrap(); write_runtime_status(&root.join("run/runtime-config-status.json"), "r1"); let systemctl_fixture = root.join("systemctl.txt"); @@ -92,10 +228,7 @@ fn miner_health_distinguishes_ready_external_wait_degraded_blocked_and_unknown() fs::write( &journal_fixture, - concat!( - "miner speed 10s/60s/15m 340.0 341.0 n/a H/s\n", - "cpu accepted (7/0) diff 10000\n" - ), + "net connect error: stale journal evidence\n", ) .unwrap(); let ready = run_observer( @@ -104,44 +237,56 @@ fn miner_health_distinguishes_ready_external_wait_degraded_blocked_and_unknown() &journalctl, &systemctl_fixture, &journal_fixture, + Some(ready_summary()), ); assert_eq!(ready["state"], "ready"); + assert_eq!(ready["reason"], Value::Null); assert_eq!(ready["unit"]["restart_count"], 2); + assert_eq!(ready["evidence"]["source"], "xmrig_http_api"); assert_eq!(ready["evidence"]["accepted_shares"], 7); assert_eq!(ready["evidence"]["rejected_shares"], 0); assert_eq!(ready["remediation"], "observe_only"); - fs::write(&journal_fixture, "net connect error: connection refused\n").unwrap(); + fs::write( + &journal_fixture, + "miner speed 10s/60s/15m 340.0 341.0 339.0 H/s\n", + ) + .unwrap(); let waiting = run_observer( &root, &systemctl, &journalctl, &systemctl_fixture, &journal_fixture, + Some(disconnected_summary()), ); assert_eq!(waiting["state"], "waiting_external"); assert_eq!(waiting["reason"], "pool_or_network_unavailable"); + assert_eq!(waiting["evidence"]["source"], "xmrig_http_api"); - fs::write(&journal_fixture, "").unwrap(); let degraded = run_observer( &root, &systemctl, &journalctl, &systemctl_fixture, &journal_fixture, + Some(missing_current_hashrate_summary()), ); assert_eq!(degraded["state"], "degraded"); - assert_eq!(degraded["reason"], "no_recent_speed_evidence"); + assert_eq!(degraded["reason"], "current_hashrate_unavailable"); + assert_eq!(degraded["evidence"]["source"], "xmrig_http_api"); + fs::write(&journal_fixture, "").unwrap(); let unknown = run_observer( &root, &systemctl, &journalctl_fail, &systemctl_fixture, &journal_fixture, + None, ); assert_eq!(unknown["state"], "unknown"); - assert_eq!(unknown["reason"], "journal_unavailable"); + assert_eq!(unknown["reason"], "api_unavailable"); assert_eq!(unknown["evidence"]["journal_available"], false); write_runtime_status(&root.join("run/runtime-config-status.json"), "r2"); @@ -151,6 +296,7 @@ fn miner_health_distinguishes_ready_external_wait_degraded_blocked_and_unknown() &journalctl, &systemctl_fixture, &journal_fixture, + Some(ready_summary()), ); assert_eq!(blocked["state"], "blocked"); assert_eq!(blocked["reason"], "runtime_revision_mismatch"); diff --git a/crates/rigos-config/tests/randomx_build_entrypoint.rs b/crates/rigos-config/tests/randomx_build_entrypoint.rs new file mode 100644 index 00000000..8e19bb59 --- /dev/null +++ b/crates/rigos-config/tests/randomx_build_entrypoint.rs @@ -0,0 +1,47 @@ +use std::fs; +use std::path::PathBuf; + +fn repo_path(path: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .join(path) +} + +#[test] +fn performance_entrypoint_uses_exact_lf_git_version_authority() { + let attributes = fs::read_to_string(repo_path(".gitattributes")).unwrap(); + let entrypoint = + fs::read_to_string(repo_path("scripts/build-usb-image-entrypoint.sh")).unwrap(); + let image_verifier = + fs::read_to_string(repo_path("scripts/verify-randomx-performance-image.sh")).unwrap(); + let image_hook = fs::read_to_string(repo_path("build/usb/hooks/010-rigos.chroot")).unwrap(); + + assert!( + attributes + .lines() + .any(|line| line == "build/usb/version.env text eol=lf") + ); + assert!(entrypoint.contains( + "git -c safe.directory=\"$repo\" show HEAD:build/usb/version.env >\"$version_env\"" + )); + assert!(entrypoint.contains("if grep -q $'\\r' \"$version_env\"; then")); + assert!(entrypoint.contains("source \"$version_env\"")); + assert!(!entrypoint.contains("source ./build/usb/version.env")); + assert!(entrypoint.contains("rigos-randomx-msr")); + assert!(entrypoint.contains("rigos-miner-gate")); + assert!(entrypoint.contains("--test randomx_build_entrypoint")); + + assert!(image_hook.contains("/usr/lib/rigos/rigos-randomx-msr")); + assert!(image_hook.contains("rigos-randomx-msr.service rigos-miner.service")); + + assert!(image_verifier.contains("msr_support=\"module\"")); + assert!(image_verifier.contains("msr_support=\"builtin\"")); + assert!(image_verifier.contains("modules.builtin")); + assert!(image_verifier.contains("kernel/arch/x86/kernel/msr\\.ko")); + assert!(image_verifier.contains("Do not use grep -q in a pipe while pipefail is enabled")); + assert!(!image_verifier.contains("grep -Eq")); + assert!( + image_verifier + .contains("kernel MSR support is absent from module files and modules.builtin") + ); +} diff --git a/crates/rigos-config/tests/randomx_msr_authority.rs b/crates/rigos-config/tests/randomx_msr_authority.rs new file mode 100644 index 00000000..4cd8e11e --- /dev/null +++ b/crates/rigos-config/tests/randomx_msr_authority.rs @@ -0,0 +1,298 @@ +#![cfg(unix)] + +use serde_json::Value; +use std::fs::{self, File, OpenOptions}; +use std::io::{Seek, SeekFrom, Write}; +use std::os::unix::fs::{FileExt, PermissionsExt, symlink}; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::{SystemTime, UNIX_EPOCH}; + +const REGISTER: u64 = 0x1a4; +const TARGET: u64 = 0x0f; + +fn repo_path(path: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .join(path) +} + +fn unique_root(name: &str) -> PathBuf { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + std::env::temp_dir().join(format!("rigos-{name}-{unique}")) +} + +fn write_cpuinfo(path: &Path, model: u32) { + fs::write( + path, + format!( + "processor : 0\nvendor_id : GenuineIntel\ncpu family : 6\nmodel : {model}\nmodel name : Synthetic CPU\n\n" + ), + ) + .unwrap(); +} + +fn create_msr(path: &Path, value: u64) { + fs::create_dir_all(path.parent().unwrap()).unwrap(); + let mut file = OpenOptions::new() + .create_new(true) + .read(true) + .write(true) + .open(path) + .unwrap(); + file.seek(SeekFrom::Start(REGISTER)).unwrap(); + file.write_all(&value.to_le_bytes()).unwrap(); + file.sync_all().unwrap(); +} + +fn read_msr(path: &Path) -> u64 { + let file = File::open(path).unwrap(); + let mut bytes = [0_u8; 8]; + file.read_exact_at(&mut bytes, REGISTER).unwrap(); + u64::from_le_bytes(bytes) +} + +fn run_authority(root: &Path, command: &str) -> std::process::Output { + let script = repo_path("build/usb/includes.chroot/usr/lib/rigos/rigos-randomx-msr"); + Command::new("/usr/bin/python3") + .arg(script) + .arg(command) + .arg("--cpuinfo") + .arg(root.join("cpuinfo")) + .arg("--online") + .arg(root.join("online")) + .arg("--device-root") + .arg(root.join("dev/cpu")) + .arg("--boot-id") + .arg(root.join("boot_id")) + .arg("--status") + .arg(root.join("run/status.json")) + .arg("--state") + .arg(root.join("run/state.json")) + .arg("--lock") + .arg(root.join("run/authority.lock")) + .output() + .unwrap() +} + +fn prepare_miner_gate_fixture(root: &Path) -> PathBuf { + let state = root.join("miner-state"); + let revision = state.join("revisions/r1"); + fs::create_dir_all(&revision).unwrap(); + symlink("revisions/r1", state.join("current")).unwrap(); + fs::write( + revision.join("policy.json"), + r#"{"schema":"rigos.policy/v1","miner_start_mode":"on_boot"}"#, + ) + .unwrap(); + fs::write(revision.join("xmrig.json"), r#"{"autosave":false}"#).unwrap(); + fs::write(root.join("cmdline"), "boot=live console=tty0\n").unwrap(); + state +} + +fn run_miner_gate(root: &Path) -> std::process::Output { + let state = prepare_miner_gate_fixture(root); + let gate = repo_path("build/usb/includes.chroot/usr/lib/rigos/rigos-miner-gate"); + Command::new("/usr/bin/python3") + .arg(gate) + .arg("--state") + .arg(state) + .arg("--cmdline") + .arg(root.join("cmdline")) + .arg("--msr-status") + .arg(root.join("run/status.json")) + .arg("--msr-state") + .arg(root.join("run/state.json")) + .arg("--boot-id") + .arg(root.join("boot_id")) + .output() + .unwrap() +} + +fn status(root: &Path) -> Value { + serde_json::from_slice(&fs::read(root.join("run/status.json")).unwrap()).unwrap() +} + +#[test] +fn source_wiring_is_optional_reversible_and_narrow() { + let service = fs::read_to_string(repo_path( + "build/usb/includes.chroot/etc/systemd/system/rigos-randomx-msr.service", + )) + .unwrap(); + let dropin = fs::read_to_string(repo_path( + "build/usb/includes.chroot/etc/systemd/system/rigos-miner.service.d/randomx-msr.conf", + )) + .unwrap(); + let packages = + fs::read_to_string(repo_path("build/usb/package-lists/rigos.list.chroot")).unwrap(); + let authority = fs::read_to_string(repo_path( + "build/usb/includes.chroot/usr/lib/rigos/rigos-randomx-msr", + )) + .unwrap(); + let miner_gate = fs::read_to_string(repo_path( + "build/usb/includes.chroot/usr/lib/rigos/rigos-miner-gate", + )) + .unwrap(); + + for required in [ + "ExecStartPre=-/usr/sbin/modprobe msr", + "ExecStart=/usr/bin/python3 /usr/lib/rigos/rigos-randomx-msr apply", + "ExecStop=/usr/bin/python3 /usr/lib/rigos/rigos-randomx-msr restore", + "CapabilityBoundingSet=CAP_SYS_MODULE CAP_SYS_RAWIO", + "ReadWritePaths=/run/rigos -/dev/cpu", + ] { + assert!(service.contains(required), "service is missing {required}"); + } + assert!(dropin.contains("Wants=rigos-randomx-msr.service")); + assert!(dropin.contains("After=rigos-randomx-msr.service")); + assert!(!dropin.contains("Requires=rigos-randomx-msr.service")); + assert!(packages.lines().any(|line| line == "kmod")); + assert!(authority.contains("SUPPORTED_CPUS = {(\"GenuineIntel\", 6, 42)}")); + assert!(authority.contains("REGISTER = 0x1A4")); + assert!(authority.contains("TARGET_VALUE = 0xF")); + assert!(authority.contains("apply_failed_rolled_back")); + assert!(authority.contains("apply_failed_rollback_incomplete")); + assert!(authority.contains("stale_state_discarded")); + assert!(miner_gate.contains("validate_msr_authority")); + assert!(miner_gate.contains("randomx_msr_authority_unsafe")); + assert!(miner_gate.contains("randomx_msr_status_stale")); + assert!(miner_gate.contains("randomx_msr_restore_state_missing")); +} + +#[test] +fn supported_cpu_apply_is_idempotent_and_restore_recovers_original_values() { + let root = unique_root("randomx-msr-roundtrip"); + fs::create_dir_all(root.join("run")).unwrap(); + write_cpuinfo(&root.join("cpuinfo"), 42); + fs::write(root.join("online"), "0-1\n").unwrap(); + fs::write( + root.join("boot_id"), + "00000000-0000-0000-0000-000000000001\n", + ) + .unwrap(); + let cpu0 = root.join("dev/cpu/0/msr"); + let cpu1 = root.join("dev/cpu/1/msr"); + create_msr(&cpu0, 0x10); + create_msr(&cpu1, 0x20); + + let applied = run_authority(&root, "apply"); + assert!( + applied.status.success(), + "{}", + String::from_utf8_lossy(&applied.stderr) + ); + assert_eq!(read_msr(&cpu0), TARGET); + assert_eq!(read_msr(&cpu1), TARGET); + let first = status(&root); + assert_eq!(first["outcome"], "ready"); + assert!(first["reason"].is_null()); + assert_eq!( + fs::metadata(root.join("run/state.json")) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o600 + ); + + let gate = run_miner_gate(&root); + assert!( + gate.status.success(), + "{}", + String::from_utf8_lossy(&gate.stderr) + ); + + let repeated = run_authority(&root, "apply"); + assert!(repeated.status.success()); + let second = status(&root); + assert_eq!(second["outcome"], "ready"); + assert_eq!(second["reason"], "already_applied"); + + let restored = run_authority(&root, "restore"); + assert!( + restored.status.success(), + "{}", + String::from_utf8_lossy(&restored.stderr) + ); + assert_eq!(read_msr(&cpu0), 0x10); + assert_eq!(read_msr(&cpu1), 0x20); + assert!(!root.join("run/state.json").exists()); + let final_status = status(&root); + assert_eq!(final_status["outcome"], "restored"); + assert!(final_status["reason"].is_null()); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn unsupported_cpu_is_truthful_and_never_requires_msr_devices() { + let root = unique_root("randomx-msr-unsupported"); + fs::create_dir_all(root.join("run")).unwrap(); + write_cpuinfo(&root.join("cpuinfo"), 58); + fs::write(root.join("online"), "0-3\n").unwrap(); + fs::write( + root.join("boot_id"), + "00000000-0000-0000-0000-000000000002\n", + ) + .unwrap(); + + let output = run_authority(&root, "apply"); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let value = status(&root); + assert_eq!(value["outcome"], "unsupported"); + assert_eq!(value["reason"], "cpu_not_allowlisted"); + assert!(!root.join("run/state.json").exists()); + + let gate = run_miner_gate(&root); + assert!( + gate.status.success(), + "{}", + String::from_utf8_lossy(&gate.stderr) + ); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn partial_write_failure_rolls_back_every_recoverable_cpu_and_keeps_state() { + let root = unique_root("randomx-msr-rollback"); + fs::create_dir_all(root.join("run")).unwrap(); + write_cpuinfo(&root.join("cpuinfo"), 42); + fs::write(root.join("online"), "0-1\n").unwrap(); + fs::write( + root.join("boot_id"), + "00000000-0000-0000-0000-000000000003\n", + ) + .unwrap(); + let cpu0 = root.join("dev/cpu/0/msr"); + create_msr(&cpu0, 0x55); + fs::create_dir_all(root.join("dev/cpu/1")).unwrap(); + symlink("/dev/full", root.join("dev/cpu/1/msr")).unwrap(); + + let output = run_authority(&root, "apply"); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!(read_msr(&cpu0), 0x55); + let value = status(&root); + assert_eq!(value["outcome"], "degraded"); + assert_eq!(value["reason"], "apply_failed_rollback_incomplete"); + assert_eq!(value["rollback"]["attempted"], true); + assert_eq!(value["rollback"]["complete"], false); + assert!(root.join("run/state.json").exists()); + + let gate = run_miner_gate(&root); + assert_eq!(gate.status.code(), Some(2)); + assert!(String::from_utf8_lossy(&gate.stderr).contains("randomx_msr_authority_unsafe")); + + let _ = fs::remove_dir_all(root); +} diff --git a/crates/rigos-config/tests/recovery_access.rs b/crates/rigos-config/tests/recovery_access.rs index 79e156b2..fe6ff850 100644 --- a/crates/rigos-config/tests/recovery_access.rs +++ b/crates/rigos-config/tests/recovery_access.rs @@ -165,11 +165,12 @@ fn alpha8_appliance_wiring_is_explicit() { .expect("read runtime authority service"); assert!(runtime_service.contains("ExecStart=/usr/lib/rigos/rigos-runtime-authority")); - let authority = repo_path("build/usb/includes.chroot/usr/lib/rigos/rigos-runtime-authority"); - let mode = fs::metadata(authority).unwrap().permissions(); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; + let authority = + repo_path("build/usb/includes.chroot/usr/lib/rigos/rigos-runtime-authority"); + let mode = fs::metadata(authority).unwrap().permissions(); assert_ne!(mode.mode() & 0o111, 0); } diff --git a/crates/rigos-config/tests/wsl_source_gate.rs b/crates/rigos-config/tests/wsl_source_gate.rs new file mode 100644 index 00000000..1f8dbc8b --- /dev/null +++ b/crates/rigos-config/tests/wsl_source_gate.rs @@ -0,0 +1,128 @@ +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; + +fn is_repo_root(path: &Path) -> bool { + path.join("Cargo.toml").is_file() + && path.join("crates/rigos-config/Cargo.toml").is_file() + && path.join("scripts/verify.sh").is_file() +} + +fn repo_root() -> PathBuf { + let mut starts = Vec::new(); + + if let Ok(current) = env::current_dir() { + starts.push(current); + } + if let Ok(executable) = env::current_exe() { + if let Some(parent) = executable.parent() { + starts.push(parent.to_path_buf()); + } + } + + for start in starts { + let mut candidate = start; + loop { + if is_repo_root(&candidate) { + return candidate; + } + if !candidate.pop() { + break; + } + } + } + + panic!("unable to locate the RIGOS repository root at runtime"); +} + +fn repo_path(path: &str) -> PathBuf { + repo_root().join(path) +} + +#[test] +fn wsl_launcher_is_path_safe_and_fail_closed() { + let launcher = fs::read_to_string(repo_path("scripts/verify-wsl.ps1")) + .expect("read WSL source gate launcher"); + let entrypoint = fs::read_to_string(repo_path("scripts/verify-wsl-entrypoint.sh")) + .expect("read WSL source gate entrypoint"); + + for required in [ + "[string]$Repository,", + "$PSScriptRoot", + "RIGOS_WSL_SCRIPT_ROOT_UNAVAILABLE", + "$Repository.Replace([char]92, [char]47)", + "wslpath -a -- $RepositoryForWsl", + "$PathExitCode = $LASTEXITCODE", + "$ErrorActionPreference = \"Continue\"", + "verify-wsl-entrypoint.sh", + "& wsl.exe @WslPrefix -- bash $LinuxEntrypoint $LinuxRepo", + "RIGOS_WSL_SOURCE_GATE=PASS", + ] { + assert!( + launcher.contains(required), + "WSL launcher contract missing: {required}" + ); + } + + for required in [ + "set -euo pipefail", + "RIGOS_WSL_REPOSITORY_INVALID", + "for tool in cargo rustc python3 bash sh git grep rg mktemp", + "command -v \"$tool\"", + "RIGOS_WSL_TOOL_MISSING", + "for component in fmt clippy", + "RIGOS_WSL_CARGO_COMPONENT_MISSING", + "pycache_root=$(mktemp -d)", + "trap cleanup EXIT HUP INT TERM", + "export PYTHONPYCACHEPREFIX=\"$pycache_root\"", + "export PYTHONDONTWRITEBYTECODE=1", + "bash ./scripts/verify.sh", + ] { + assert!( + entrypoint.contains(required), + "WSL entrypoint contract missing: {required}" + ); + } + + let hard_coded_checkout = ["/mnt/d/TECHNICAL/", "dbyte", "-rigos"].concat(); + let forbidden = [ + "[string]$Repository = (Split-Path -Parent $PSScriptRoot)", + "wslpath -a $Repository", + "$PathConverter", + "$Shell = @'", + "bash -lc", + hard_coded_checkout.as_str(), + "curl | sh", + "Invoke-WebRequest", + "rustup-init", + "exec bash ./scripts/verify.sh", + ]; + for forbidden in forbidden { + assert!( + !launcher.contains(forbidden) && !entrypoint.contains(forbidden), + "WSL gate contains forbidden bootstrap, multiline shell transport, direct path argument, cleanup-bypassing exec, default expression, or hard-coded path: {forbidden}" + ); + } +} + +#[test] +fn python_bytecode_artifacts_are_ignored() { + let ignore = fs::read_to_string(repo_path(".gitignore")).expect("read repository ignore rules"); + for required in ["__pycache__/", "*.py[cod]"] { + assert!( + ignore.lines().any(|line| line == required), + "Python bytecode ignore rule missing: {required}" + ); + } +} + +#[test] +fn repository_root_is_resolved_at_runtime() { + let root = repo_root(); + assert!(is_repo_root(&root)); + + let source = fs::read_to_string(root.join("crates/rigos-config/tests/wsl_source_gate.rs")) + .expect("read WSL source gate test"); + let compile_time_manifest = ["CARGO", "_MANIFEST_DIR"].concat(); + assert!(!source.contains(&compile_time_manifest)); +} diff --git a/docs/developer/windows-source-gate.txt b/docs/developer/windows-source-gate.txt new file mode 100644 index 00000000..0ed96166 --- /dev/null +++ b/docs/developer/windows-source-gate.txt @@ -0,0 +1,93 @@ +RIGOS WINDOWS SOURCE GATE +========================= + +PURPOSE +------- + +Windows may compile and run cross-platform source tests. Linux remains the +authoritative environment for Unix runtime integration, image construction, +filesystem permission semantics, systemd verification, and physical gates. + +POWERSHELL RULE +--------------- + +PowerShell does not use a trailing backslash as a line continuation. Run Cargo +commands on one line, or use PowerShell's backtick continuation explicitly. + +BASED RECOVERY RULE +------------------- + +The historical raw HiveOS filesystem import must not remain reachable from any +production branch, tag, or local Git backup branch. Preserve any required +reference outside Git in encrypted or offline storage, then use a fresh +single-branch clone as the canonical development checkout. + +Never rebase the historical BASED commits onto a production branch. + +WINDOWS COMMANDS +---------------- + + cargo fmt --all -- --check + cargo test --locked -p rigos-config --test miner_observer_authority -- --nocapture + cargo test --locked -p rigos-config --test wsl_source_gate -- --nocapture + cargo test --locked -p rigos-config --no-run + +The following integration targets are intentionally Unix-only and must retain a +file-level #![cfg(unix)] gate: + + crates/rigos-config/tests/miner_stability.rs + crates/rigos-config/tests/hive_exit_runtime_publish.rs + crates/rigos-config/tests/randomx_msr_authority.rs + +LINUX AUTHORITY FROM POWERSHELL +------------------------------- + +Run the fail-closed launcher from the repository root: + + powershell -ExecutionPolicy Bypass -File .\scripts\verify-wsl.ps1 + +The launcher: + + - derives the repository from its own location + - normalizes Windows backslashes before invoking wslpath + - invokes scripts/verify-wsl-entrypoint.sh as a real file + - never transports multiline Bash source through a native command argument + - sources $HOME/.cargo/env when present + - checks cargo, rustc, python3, bash, sh, git, grep, rg, and mktemp + - checks the Rust fmt and clippy cargo components + - does not download or install tools + - executes scripts/verify.sh only after every prerequisite is present + +To select a specific installed WSL distribution: + + powershell -ExecutionPolicy Bypass -File .\scripts\verify-wsl.ps1 -Distribution Ubuntu + +or set: + + $env:RIGOS_WSL_DISTRO = "Ubuntu" + +A missing executable or Rust component is reported as: + + RIGOS_WSL_TOOL_MISSING= + RIGOS_WSL_CARGO_COMPONENT_MISSING= + +Install the missing prerequisite inside the selected WSL distribution and rerun +the launcher. Do not type /mnt paths or source commands directly into +PowerShell. + +DIRECT LINUX COMMANDS +--------------------- + +Inside an actual WSL/Linux shell with Linux Rust installed, change to the +repository checkout selected by the operator: + + cd /path/to/rigos-checkout + source "$HOME/.cargo/env" + cargo --version + bash ./scripts/verify.sh + +EVIDENCE RULE +------------- + +A Windows compile PASS is not a Linux runtime PASS. A Linux source PASS is not +an exact-image PASS. Exact-image and physical PASS remain separate gates. diff --git a/scripts/build-usb-image-entrypoint.sh b/scripts/build-usb-image-entrypoint.sh new file mode 100644 index 00000000..78e1ef70 --- /dev/null +++ b/scripts/build-usb-image-entrypoint.sh @@ -0,0 +1,52 @@ +#!/bin/bash +set -euo pipefail + +repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$repo" + +version_env="$(mktemp)" +cleanup() { + rm -f "$version_env" +} +trap cleanup EXIT + +git -c safe.directory="$repo" show HEAD:build/usb/version.env >"$version_env" +if grep -q $'\r' "$version_env"; then + printf 'build-usb-image-entrypoint: Git version authority contains CR bytes\n' >&2 + exit 1 +fi + +# shellcheck disable=SC1090 +source "$version_env" + +python3 ./scripts/check-alpha8-ssh-hotfix.py +python3 ./scripts/verify-systemd-ordering.py +python3 -m py_compile \ + ./build/usb/includes.chroot/usr/lib/rigos/rigos-randomx-msr \ + ./build/usb/includes.chroot/usr/lib/rigos/rigos-miner-gate \ + ./build/usb/includes.chroot/usr/lib/rigos/rigos-ssh-hostkeys \ + ./build/usb/includes.chroot/usr/lib/rigos/rigos-runtime-render \ + ./build/usb/includes.chroot/usr/lib/rigos/rigos-miner-health \ + ./scripts/test-miner-health-api.py \ + ./scripts/test-miner-health-api-authority-errors.py \ + ./scripts/test-miner-health-api-schema.py \ + ./scripts/test-miner-health-connection-state.py \ + ./scripts/test-miner-health-journal-fallback.py \ + ./scripts/test-runtime-token-publication.py +python3 ./scripts/test-miner-health-api.py +python3 ./scripts/test-miner-health-api-authority-errors.py +python3 ./scripts/test-miner-health-api-schema.py +python3 ./scripts/test-miner-health-connection-state.py +python3 ./scripts/test-miner-health-journal-fallback.py +python3 ./scripts/test-runtime-token-publication.py + +export CARGO_TARGET_DIR=/work/rigos-performance-preflight-target +cargo test --locked -p rigos-config --test miner_observer_authority -- --nocapture +cargo test --locked -p rigos-config --test randomx_build_entrypoint -- --nocapture +cargo test --locked -p rigos-config --test randomx_msr_authority -- --nocapture + +./scripts/build-usb-image.sh + +image="./dist/usb/${RIGOS_IMAGE_ID}-${RIGOS_IMAGE_VERSION}.img" +bash ./scripts/verify-randomx-performance-image.sh "$image" +bash ./scripts/verify-miner-observer-image.sh "$image" diff --git a/scripts/check-alpha8-ssh-hotfix.py b/scripts/check-alpha8-ssh-hotfix.py index 17a154e3..5c513ce8 100644 --- a/scripts/check-alpha8-ssh-hotfix.py +++ b/scripts/check-alpha8-ssh-hotfix.py @@ -4,21 +4,34 @@ ROOT = Path(__file__).resolve().parents[1] POLICY = ROOT / "build/usb/includes.chroot/etc/ssh/sshd_config.d/00-rigos.conf" +HOSTKEY_POLICY = ROOT / "build/usb/includes.chroot/etc/ssh/sshd_config.d/01-rigos-hostkeys.conf" PACKAGES = ROOT / "build/usb/package-lists/rigos.list.chroot" HOOK = ROOT / "build/usb/hooks/010-rigos.chroot" DOCKERFILE = ROOT / "build/usb/Dockerfile" RECOVERY_UNIT = ROOT / "build/usb/includes.chroot/etc/systemd/system/rigos-recovery-access.service" RECOVERY_GATE = ROOT / "build/usb/includes.chroot/usr/lib/rigos/rigos-recovery-access-verify" +STATE_READY_UNIT = ROOT / "build/usb/includes.chroot/etc/systemd/system/rigos-state-ready.service" +HOSTKEY_UNIT = ROOT / "build/usb/includes.chroot/etc/systemd/system/rigos-ssh-hostkeys.service" +SSH_DROPIN = ROOT / "build/usb/includes.chroot/etc/systemd/system/ssh.service.d/rigos-observe.conf" +HOSTKEY_AUTHORITY = ROOT / "build/usb/includes.chroot/usr/lib/rigos/rigos-ssh-hostkeys" +SSH_DIRECTORY = ROOT / "build/usb/includes.chroot/etc/ssh" EXPECTED_POLICY_SHA256 = "d59b6bcc078a047d1f1cc90ef6ed9205476d91f874be809009bdd442ef66b8c3" def normalized_lf_bytes(path: Path) -> bytes: raw = path.read_bytes() if raw.startswith(b"\xef\xbb\xbf"): - raise RuntimeError("Alpha8 SSH policy must be UTF-8 without BOM") + raise RuntimeError(f"{path.name} must be UTF-8 without BOM") return raw.replace(b"\r\n", b"\n").replace(b"\r", b"\n") +def require_lines(path: Path, required_lines: tuple[str, ...]) -> None: + lines = set(path.read_text(encoding="utf-8").splitlines()) + for required in required_lines: + if required not in lines: + raise RuntimeError(f"{path.name} contract is missing: {required}") + + def main() -> int: policy = normalized_lf_bytes(POLICY) observed = hashlib.sha256(policy).hexdigest() @@ -29,9 +42,62 @@ def main() -> int: packages = PACKAGES.read_text(encoding="utf-8").splitlines() if "openssh-server" not in packages: raise RuntimeError("OpenSSH server package is missing") + + hostkey_policy = normalized_lf_bytes(HOSTKEY_POLICY) + if hostkey_policy != b"HostKey /var/lib/rigos/system/ssh-hostkeys/ssh_host_ed25519_key\n": + raise RuntimeError("persistent SSH HostKey policy is not exact") + baked_keys = sorted(path for path in SSH_DIRECTORY.glob("ssh_host_*_key*") if path.is_file()) + if baked_keys: + raise RuntimeError("appliance source contains a baked SSH host private or public key") + hook = HOOK.read_text(encoding="utf-8") - if "ssh.service" not in hook or "systemctl disable ssh.socket" not in hook: - raise RuntimeError("deterministic SSH service wiring is missing") + for required in ( + "ssh.service", + "systemctl disable ssh.socket", + "rigos-ssh-hostkeys.service", + "/usr/lib/rigos/rigos-ssh-hostkeys", + "rm -f /etc/ssh/ssh_host_*_key /etc/ssh/ssh_host_*_key.pub", + "install -d -o root -g rigos -m 0750 /var/lib/rigos", + ): + if required not in hook: + raise RuntimeError(f"deterministic SSH service wiring is missing: {required}") + + authority = HOSTKEY_AUTHORITY.read_text(encoding="utf-8") + compile(authority, str(HOSTKEY_AUTHORITY), "exec") + for required in ( + 'STATE = Path("/var/lib/rigos")', + 'KEYS = SYSTEM / "ssh-hostkeys"', + '"schema": "rigos.ssh-hostkeys/v1"', + 'os.rename(temporary, KEYS)', + 'or status.get("outcome") != "ready"', + '"persistent SSH public and private keys do not match"', + '"persistent SSH host identity exists without a valid manifest"', + ): + if required not in authority: + raise RuntimeError(f"persistent SSH host-key authority contract is missing: {required}") + + require_lines( + HOSTKEY_UNIT, + ( + "After=rigos-state-ready.service", + "Requires=rigos-state-ready.service", + "Before=ssh.service", + "ExecStart=/usr/lib/rigos/rigos-ssh-hostkeys", + "ReadWritePaths=/var/lib/rigos /run/rigos", + "WantedBy=multi-user.target", + ), + ) + require_lines( + SSH_DROPIN, + ( + "After=rigos-recovery-access.service rigos-ssh-hostkeys.service", + "Requires=rigos-ssh-hostkeys.service", + "Wants=rigos-remote-access-observe.service", + ), + ) + if "Before=rigos-ssh-hostkeys.service" not in STATE_READY_UNIT.read_text(encoding="utf-8"): + raise RuntimeError("state readiness is not ordered before persistent SSH identity") + dockerfile = DOCKERFILE.read_text(encoding="utf-8") if 'ENV PATH="/usr/local/cargo/bin:/usr/local/rustup/bin:${PATH}"' not in dockerfile: raise RuntimeError("builder Cargo PATH is not explicit") @@ -62,7 +128,7 @@ def main() -> int: if required not in recovery_gate: raise RuntimeError(f"recovery access validator contract is missing: {required}") - print("RIGOS Alpha8 SSH and recovery hotfix verification passed") + print("RIGOS Alpha8 SSH, recovery, and persistent host-key verification passed") return 0 diff --git a/scripts/test-miner-health-api-authority-errors.py b/scripts/test-miner-health-api-authority-errors.py new file mode 100644 index 00000000..1db3865c --- /dev/null +++ b/scripts/test-miner-health-api-authority-errors.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +import importlib.machinery +import importlib.util +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +MINER_HEALTH = ROOT / "build/usb/includes.chroot/usr/lib/rigos/rigos-miner-health" +PROPERTIES = {"ActiveState": "active", "SubState": "running", "MainPID": "123"} + + +def load_source(name: str, path: Path): + loader = importlib.machinery.SourceFileLoader(name, str(path)) + spec = importlib.util.spec_from_loader(name, loader) + if spec is None: + raise RuntimeError(f"could not load {path}") + module = importlib.util.module_from_spec(spec) + loader.exec_module(module) + return module + + +def classify(module, api_error: str | None, journal: str, journal_available: bool = True): + return module.classify( + PROPERTIES, + "S", + 600, + "r1", + "ready", + "r1", + None, + api_error, + journal, + journal_available, + ) + + +class MinerHealthApiAuthorityErrorTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.module = load_source("rigos_miner_health_api_authority_errors", MINER_HEALTH) + + def test_missing_token_cannot_be_hidden_by_ready_journal(self): + state = classify( + self.module, + "api_token_missing", + "miner speed 10s/60s/15m 341.2 340.9 340.7 H/s", + ) + self.assertEqual(state, ("degraded", "api_token_missing")) + + def test_authentication_failure_cannot_be_hidden_by_accepted_share(self): + state = classify( + self.module, + "api_http_status_401", + "cpu accepted (43/0) diff 10000", + ) + self.assertEqual(state, ("degraded", "api_http_status_401")) + + def test_invalid_api_response_cannot_be_hidden_by_journal(self): + state = classify( + self.module, + "api_response_not_object", + "cpu accepted (43/0) diff 10000", + ) + self.assertEqual(state, ("degraded", "api_response_not_object")) + + def test_transient_api_unavailable_still_allows_journal_fallback(self): + state = classify( + self.module, + "api_unavailable", + "cpu accepted (43/0) diff 10000", + ) + self.assertEqual(state, ("ready", None)) + + def test_transient_api_unavailable_without_journal_is_unknown(self): + state = classify(self.module, "api_unavailable", "", journal_available=False) + self.assertEqual(state, ("unknown", "api_unavailable")) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/scripts/test-miner-health-api-schema.py b/scripts/test-miner-health-api-schema.py new file mode 100644 index 00000000..297f08c7 --- /dev/null +++ b/scripts/test-miner-health-api-schema.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +import importlib.machinery +import importlib.util +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +MINER_HEALTH = ROOT / "build/usb/includes.chroot/usr/lib/rigos/rigos-miner-health" + + +def load_source(name: str, path: Path): + loader = importlib.machinery.SourceFileLoader(name, str(path)) + spec = importlib.util.spec_from_loader(name, loader) + if spec is None: + raise RuntimeError(f"could not load {path}") + module = importlib.util.module_from_spec(spec) + loader.exec_module(module) + return module + + +class MinerHealthApiSchemaTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.module = load_source("rigos_miner_health_api_schema", MINER_HEALTH) + + def test_fractional_counters_are_rejected_not_truncated(self): + metrics = self.module.summary_metrics({ + "hashrate": {"total": [341.2, 340.9, 340.7]}, + "connection": { + "pool": "pool.example:1234", + "ip": "203.0.113.10", + "uptime": 10, + "accepted": 43.9, + "rejected": 1.2, + "failures": 2.5, + "ping": 109.7, + }, + "results": { + "shares_good": 42.8, + "shares_total": 44.1, + }, + "hugepages": [1168.5, 1169.5], + }) + + self.assertIsNone(metrics["accepted_shares"]) + self.assertIsNone(metrics["rejected_shares"]) + self.assertIsNone(metrics["connection_failures"]) + self.assertIsNone(metrics["pool_ping_ms"]) + self.assertIsNone(metrics["hugepages_used"]) + self.assertIsNone(metrics["hugepages_total"]) + + def test_fractional_uptime_cannot_assert_active_connection(self): + metrics = self.module.summary_metrics({ + "uptime": 900.5, + "connection": { + "pool": "pool.example:1234", + "ip": None, + "uptime": 0.1, + "uptime_ms": 0.5, + }, + }) + + self.assertIsNone(metrics["uptime_seconds"]) + self.assertIsNone(metrics["connection_uptime_seconds"]) + self.assertIsNone(metrics["connection_uptime_ms"]) + self.assertFalse(metrics["pool_connected"]) + + def test_boolean_counters_are_rejected(self): + metrics = self.module.summary_metrics({ + "connection": {"accepted": True, "rejected": False}, + "hugepages": [True, False], + }) + + self.assertIsNone(metrics["accepted_shares"]) + self.assertIsNone(metrics["rejected_shares"]) + self.assertIsNone(metrics["hugepages_used"]) + self.assertIsNone(metrics["hugepages_total"]) + + def test_integer_result_fallback_remains_supported(self): + metrics = self.module.summary_metrics({ + "results": {"shares_good": 43, "shares_total": 45}, + "hugepages": [1168, 1168], + "connection": {"failures": 2, "ping": 109}, + }) + + self.assertEqual(metrics["accepted_shares"], 43) + self.assertEqual(metrics["rejected_shares"], 2) + self.assertEqual(metrics["connection_failures"], 2) + self.assertEqual(metrics["pool_ping_ms"], 109) + self.assertEqual(metrics["hugepages_used"], 1168) + self.assertEqual(metrics["hugepages_total"], 1168) + + def test_total_below_accepted_does_not_produce_negative_rejects(self): + metrics = self.module.summary_metrics({ + "results": {"shares_good": 45, "shares_total": 43}, + }) + + self.assertEqual(metrics["accepted_shares"], 45) + self.assertIsNone(metrics["rejected_shares"]) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/scripts/test-miner-health-api.py b/scripts/test-miner-health-api.py new file mode 100644 index 00000000..d2068cbe --- /dev/null +++ b/scripts/test-miner-health-api.py @@ -0,0 +1,267 @@ +#!/usr/bin/env python3 +import importlib.machinery +import importlib.util +import json +import os +import stat +import tempfile +import threading +import unittest +from http.server import BaseHTTPRequestHandler, HTTPServer +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +RUNTIME_RENDER = ROOT / "build/usb/includes.chroot/usr/lib/rigos/rigos-runtime-render" +MINER_HEALTH = ROOT / "build/usb/includes.chroot/usr/lib/rigos/rigos-miner-health" + + +def load_source(name: str, path: Path): + loader = importlib.machinery.SourceFileLoader(name, str(path)) + spec = importlib.util.spec_from_loader(name, loader) + if spec is None: + raise RuntimeError(f"could not load {path}") + module = importlib.util.module_from_spec(spec) + loader.exec_module(module) + return module + + +class Environment: + def __init__(self, **values: str): + self.values = values + self.original: dict[str, str | None] = {} + + def __enter__(self): + for key, value in self.values.items(): + self.original[key] = os.environ.get(key) + os.environ[key] = value + return self + + def __exit__(self, _type, _value, _traceback): + for key, value in self.original.items(): + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + + +class RuntimeRenderApiTests(unittest.TestCase): + def test_runtime_api_is_local_authenticated_redacted_and_idempotent(self): + with tempfile.TemporaryDirectory(prefix="rigos-render-api-") as temporary: + root = Path(temporary) + state = root / "state" + runtime = root / "run" + revision = state / "revisions/r1" + (revision / "flight-sheets").mkdir(parents=True) + (revision / "policy.json").write_text( + json.dumps({ + "schema": "rigos.policy/v1", + "active_flight_sheet": "sheet", + }), + encoding="utf-8", + ) + (revision / "flight-sheets/sheet.json").write_text( + json.dumps({ + "schema": "rigos.flight-sheet/v1", + "backend": "xmrig", + "algorithm": "rx/0", + "cpu": {"threads": "auto"}, + }), + encoding="utf-8", + ) + (revision / "xmrig.json").write_text( + json.dumps({ + "autosave": False, + "cpu": {"enabled": True, "huge-pages": True}, + "pools": [{ + "url": "pool.example:1234", + "user": "secret-wallet", + "pass": "worker", + "algo": "rx/0", + }], + "http": { + "enabled": True, + "host": "0.0.0.0", + "port": 9999, + "access-token": "attacker-controlled", + "restricted": False, + }, + }), + encoding="utf-8", + ) + state.mkdir(exist_ok=True) + (state / "current").symlink_to("revisions/r1") + + with Environment( + RIGOS_STATE_PATH=str(state), + RIGOS_RUNTIME_PATH=str(runtime), + RIGOS_RENDER_SKIP_CHOWN="1", + ): + module = load_source("rigos_runtime_render_test", RUNTIME_RENDER) + private_first, public_first, status_first = module.render() + token_path = runtime / "xmrig-api-token" + token_first = token_path.read_text(encoding="ascii").strip() + + self.assertEqual(private_first["http"]["host"], "127.0.0.1") + self.assertEqual(private_first["http"]["port"], 18080) + self.assertTrue(private_first["http"]["enabled"]) + self.assertTrue(private_first["http"]["restricted"]) + self.assertEqual(private_first["http"]["access-token"], token_first) + self.assertNotIn("access-token", public_first["http"]) + self.assertNotIn("secret-wallet", json.dumps(public_first)) + self.assertEqual(status_first["http_api"]["host"], "127.0.0.1") + self.assertEqual(status_first["http_api"]["port"], 18080) + self.assertNotIn(token_first, json.dumps(status_first)) + self.assertEqual(stat.S_IMODE(token_path.stat().st_mode), 0o600) + + private_second, _, _ = module.render() + self.assertEqual(private_second["http"]["access-token"], token_first) + self.assertEqual(token_path.read_text(encoding="ascii").strip(), token_first) + + self.assertEqual(module.main(), 0) + self.assertEqual(stat.S_IMODE((runtime / "xmrig.json").stat().st_mode), 0o640) + self.assertEqual(stat.S_IMODE((runtime / "xmrig-public.json").stat().st_mode), 0o644) + persisted_public = json.loads((runtime / "xmrig-public.json").read_text(encoding="utf-8")) + self.assertNotIn("access-token", persisted_public["http"]) + + +class SummaryHandler(BaseHTTPRequestHandler): + expected_token = "" + summary: dict = {} + + def do_GET(self): + if self.path != "/2/summary": + self.send_response(404) + self.end_headers() + return + if self.headers.get("Authorization") != f"Bearer {self.expected_token}": + self.send_response(401) + self.end_headers() + return + payload = json.dumps(self.summary).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def log_message(self, _format, *_args): + return + + +class MinerHealthApiTests(unittest.TestCase): + def test_authenticated_summary_drives_health_truth(self): + with tempfile.TemporaryDirectory(prefix="rigos-health-api-") as temporary: + root = Path(temporary) + token = "A" * 48 + token_path = root / "xmrig-api-token" + token_path.write_text(token + "\n", encoding="ascii") + token_path.chmod(0o600) + + SummaryHandler.expected_token = token + SummaryHandler.summary = { + "uptime": 600, + "algo": "rx/0", + "hashrate": {"total": [341.2, 340.9, 340.7], "highest": 342.1}, + "connection": { + "pool": "pool.example:1234", + "uptime": 590, + "accepted": 43, + "rejected": 0, + "failures": 1, + "ping": 109, + }, + "hugepages": [1168, 1168], + } + server = HTTPServer(("127.0.0.1", 0), SummaryHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + with Environment( + RIGOS_XMRIG_API_TOKEN_PATH=str(token_path), + RIGOS_XMRIG_API_HOST="127.0.0.1", + RIGOS_XMRIG_API_PORT=str(server.server_port), + RIGOS_MINER_HEALTH_TEST_MODE="1", + ): + module = load_source("rigos_miner_health_test", MINER_HEALTH) + summary, error = module.fetch_api_summary() + self.assertIsNone(error) + self.assertIsNotNone(summary) + metrics = module.summary_metrics(summary) + self.assertEqual(metrics["hashrate_60s"], 340.9) + self.assertEqual(metrics["accepted_shares"], 43) + self.assertEqual(metrics["rejected_shares"], 0) + self.assertTrue(metrics["pool_connected"]) + self.assertEqual(metrics["hugepages_used"], 1168) + self.assertEqual(metrics["hugepages_total"], 1168) + + state, reason = module.classify( + {"ActiveState": "active", "SubState": "running", "MainPID": "123"}, + "S", + 600, + "r1", + "ready", + "r1", + metrics, + None, + "", + True, + ) + self.assertEqual((state, reason), ("ready", None)) + + no_pool = dict(metrics) + no_pool.update({ + "hashrate_10s": 0, + "hashrate_60s": 0, + "hashrate_15m": 0, + "pool_connected": False, + "pool": None, + }) + state, reason = module.classify( + {"ActiveState": "active", "SubState": "running", "MainPID": "123"}, + "S", + 600, + "r1", + "ready", + "r1", + no_pool, + None, + "", + True, + ) + self.assertEqual((state, reason), ("waiting_external", "pool_or_network_unavailable")) + + state, reason = module.classify( + {"ActiveState": "active", "SubState": "running", "MainPID": "123"}, + "S", + 600, + "r1", + "ready", + "r1", + None, + "api_unavailable", + "cpu accepted (43/0) diff 10000", + True, + ) + self.assertEqual((state, reason), ("ready", None)) + + state, reason = module.classify( + {"ActiveState": "active", "SubState": "running", "MainPID": "123"}, + "S", + 600, + "r1", + "ready", + "r1", + None, + "api_unavailable", + "", + True, + ) + self.assertEqual((state, reason), ("degraded", "api_unavailable")) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/scripts/test-miner-health-connection-state.py b/scripts/test-miner-health-connection-state.py new file mode 100644 index 00000000..564db518 --- /dev/null +++ b/scripts/test-miner-health-connection-state.py @@ -0,0 +1,203 @@ +#!/usr/bin/env python3 +import importlib.machinery +import importlib.util +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +MINER_HEALTH = ROOT / "build/usb/includes.chroot/usr/lib/rigos/rigos-miner-health" + + +def load_source(name: str, path: Path): + loader = importlib.machinery.SourceFileLoader(name, str(path)) + spec = importlib.util.spec_from_loader(name, loader) + if spec is None: + raise RuntimeError(f"could not load {path}") + module = importlib.util.module_from_spec(spec) + loader.exec_module(module) + return module + + +def classify(module, metrics): + return module.classify( + {"ActiveState": "active", "SubState": "running", "MainPID": "123"}, + "S", + 600, + "r1", + "ready", + "r1", + metrics, + None, + "", + True, + ) + + +class MinerHealthConnectionStateTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.module = load_source("rigos_miner_health_connection_state", MINER_HEALTH) + + def test_stale_pool_name_after_disconnect_is_waiting_external(self): + metrics = self.module.summary_metrics({ + "uptime": 900, + "algo": "rx/0", + "hashrate": {"total": [0, 0, 0], "highest": 341.2}, + "connection": { + "pool": "pool.example:1234", + "ip": None, + "uptime": 0, + "uptime_ms": 0, + "accepted": 43, + "rejected": 0, + "failures": 3, + "ping": 0, + }, + "hugepages": [1168, 1168], + }) + + self.assertEqual(metrics["pool"], "pool.example:1234") + self.assertIsNone(metrics["connection_ip"]) + self.assertFalse(metrics["pool_connected"]) + self.assertEqual( + classify(self.module, metrics), + ("waiting_external", "pool_or_network_unavailable"), + ) + + def test_disconnected_historical_hashrate_does_not_report_ready(self): + metrics = self.module.summary_metrics({ + "uptime": 900, + "algo": "rx/0", + "hashrate": {"total": [0, 340.8, 339.7], "highest": 341.2}, + "connection": { + "pool": "pool.example:1234", + "ip": None, + "uptime": 0, + "uptime_ms": 0, + "accepted": 43, + "rejected": 0, + "failures": 3, + "ping": 0, + }, + "hugepages": [1168, 1168], + }) + + self.assertGreater(metrics["hashrate_60s"], 0) + self.assertGreater(metrics["hashrate_15m"], 0) + self.assertFalse(metrics["pool_connected"]) + self.assertEqual( + classify(self.module, metrics), + ("waiting_external", "pool_or_network_unavailable"), + ) + + def test_active_connection_without_hashrate_is_degraded(self): + metrics = self.module.summary_metrics({ + "uptime": 900, + "algo": "rx/0", + "hashrate": {"total": [0, 0, 0], "highest": 341.2}, + "connection": { + "pool": "pool.example:1234", + "ip": "203.0.113.10", + "uptime": 0, + "uptime_ms": 125, + "accepted": 43, + "rejected": 0, + "failures": 0, + "ping": 109, + }, + "hugepages": [1168, 1168], + }) + + self.assertTrue(metrics["pool_connected"]) + self.assertEqual(metrics["connection_ip"], "203.0.113.10") + self.assertEqual(metrics["connection_uptime_ms"], 125) + self.assertEqual( + classify(self.module, metrics), + ("degraded", "no_current_hashrate_from_api"), + ) + + def test_active_historical_hashrate_without_current_rate_is_degraded(self): + metrics = self.module.summary_metrics({ + "uptime": 900, + "algo": "rx/0", + "hashrate": {"total": [0, 340.9, 340.7], "highest": 342.1}, + "connection": { + "pool": "pool.example:1234", + "ip": "203.0.113.10", + "uptime": 590, + "uptime_ms": 590125, + "accepted": 43, + "rejected": 0, + "failures": 0, + "ping": 109, + }, + "hugepages": [1168, 1168], + }) + + self.assertEqual(metrics["hashrate_10s"], 0) + self.assertGreater(metrics["hashrate_60s"], 0) + self.assertEqual( + classify(self.module, metrics), + ("degraded", "no_current_hashrate_from_api"), + ) + + def test_active_connection_with_unavailable_current_rate_is_degraded(self): + metrics = self.module.summary_metrics({ + "uptime": 900, + "algo": "rx/0", + "hashrate": {"total": [None, 340.9, 340.7], "highest": 342.1}, + "connection": { + "pool": "pool.example:1234", + "ip": "203.0.113.10", + "uptime": 590, + "uptime_ms": 590125, + "accepted": 43, + "rejected": 0, + "failures": 0, + "ping": 109, + }, + "hugepages": [1168, 1168], + }) + + self.assertIsNone(metrics["hashrate_10s"]) + self.assertEqual( + classify(self.module, metrics), + ("degraded", "current_hashrate_unavailable"), + ) + + def test_active_connection_with_hashrate_is_ready(self): + metrics = self.module.summary_metrics({ + "uptime": 900, + "algo": "rx/0", + "hashrate": {"total": [341.2, 340.9, 340.7], "highest": 342.1}, + "connection": { + "pool": "pool.example:1234", + "ip": "203.0.113.10", + "uptime": 590, + "uptime_ms": 590125, + "accepted": 43, + "rejected": 0, + "failures": 0, + "ping": 109, + }, + "hugepages": [1168, 1168], + }) + + self.assertTrue(metrics["pool_connected"]) + self.assertEqual(classify(self.module, metrics), ("ready", None)) + + def test_seconds_uptime_remains_compatible_without_ip_or_milliseconds(self): + metrics = self.module.summary_metrics({ + "hashrate": {"total": [0, 0, 0]}, + "connection": { + "pool": "pool.example:1234", + "uptime": 5, + }, + }) + + self.assertTrue(metrics["pool_connected"]) + self.assertEqual(metrics["connection_uptime_seconds"], 5) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/scripts/test-miner-health-journal-fallback.py b/scripts/test-miner-health-journal-fallback.py new file mode 100644 index 00000000..ac4595d4 --- /dev/null +++ b/scripts/test-miner-health-journal-fallback.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +import importlib.machinery +import importlib.util +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +MINER_HEALTH = ROOT / "build/usb/includes.chroot/usr/lib/rigos/rigos-miner-health" + + +def load_source(name: str, path: Path): + loader = importlib.machinery.SourceFileLoader(name, str(path)) + spec = importlib.util.spec_from_loader(name, loader) + if spec is None: + raise RuntimeError(f"could not load {path}") + module = importlib.util.module_from_spec(spec) + loader.exec_module(module) + return module + + +class MinerHealthJournalFallbackTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.module = load_source("rigos_miner_health_journal_fallback", MINER_HEALTH) + + def test_newer_external_error_overrides_old_ready_evidence(self): + journal = "\n".join([ + "miner speed 10s/60s/15m 341.2 340.9 340.7 H/s", + "cpu accepted (43/0) diff 10000", + "net connect error: operation timed out", + ]) + + self.assertEqual(self.module.latest_journal_signal(journal), "external_wait") + self.assertEqual( + self.module.journal_fallback_state(journal, 600, "api_unavailable"), + ("waiting_external", "pool_or_network_unavailable"), + ) + + def test_newer_ready_evidence_overrides_old_external_error(self): + journal = "\n".join([ + "net connect error: operation timed out", + "miner speed 10s/60s/15m 341.2 340.9 340.7 H/s", + "cpu accepted (44/0) diff 10000", + ]) + + self.assertEqual(self.module.latest_journal_signal(journal), "ready") + self.assertEqual( + self.module.journal_fallback_state(journal, 600, "api_unavailable"), + ("ready", None), + ) + + def test_no_signal_during_warmup_is_warming_up(self): + self.assertIsNone(self.module.latest_journal_signal("starting miner")) + self.assertEqual( + self.module.journal_fallback_state("starting miner", 30, "api_unavailable"), + ("warming_up", None), + ) + + def test_no_signal_after_warmup_is_degraded(self): + self.assertEqual( + self.module.journal_fallback_state("miner remains silent", 600, "api_unavailable"), + ("degraded", "api_unavailable"), + ) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/scripts/test-runtime-token-publication.py b/scripts/test-runtime-token-publication.py new file mode 100644 index 00000000..2df1a71a --- /dev/null +++ b/scripts/test-runtime-token-publication.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +import importlib.machinery +import importlib.util +import json +import os +import shutil +import stat +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +RENDERER = ROOT / "build/usb/includes.chroot/usr/lib/rigos/rigos-runtime-render" +PUBLISHER = ROOT / "build/usb/includes.chroot/usr/lib/rigos/rigos-runtime-publish" + + +def load_source(name: str, path: Path): + loader = importlib.machinery.SourceFileLoader(name, str(path)) + spec = importlib.util.spec_from_loader(name, loader) + if spec is None: + raise RuntimeError(f"could not load {path}") + module = importlib.util.module_from_spec(spec) + loader.exec_module(module) + return module + + +class Environment: + def __init__(self, **values: str): + self.values = values + self.original: dict[str, str | None] = {} + + def __enter__(self): + for key, value in self.values.items(): + self.original[key] = os.environ.get(key) + os.environ[key] = value + return self + + def __exit__(self, _type, _value, _traceback): + for key, value in self.original.items(): + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + + +def create_state(root: Path) -> Path: + state = root / "state" + revision = state / "revisions/r1" + (revision / "flight-sheets").mkdir(parents=True) + (revision / "policy.json").write_text( + json.dumps({ + "schema": "rigos.policy/v1", + "active_flight_sheet": "sheet", + }), + encoding="utf-8", + ) + (revision / "flight-sheets/sheet.json").write_text( + json.dumps({ + "schema": "rigos.flight-sheet/v1", + "backend": "xmrig", + "algorithm": "rx/0", + "cpu": {"threads": "auto"}, + }), + encoding="utf-8", + ) + (revision / "xmrig.json").write_text( + json.dumps({ + "autosave": False, + "cpu": {"enabled": True, "huge-pages": True}, + "pools": [{ + "url": "pool.example:1234", + "user": "secret-wallet", + "pass": "worker", + "algo": "rx/0", + }], + }), + encoding="utf-8", + ) + (state / "current").symlink_to("revisions/r1") + return state + + +class RuntimeTokenPublicationTests(unittest.TestCase): + def test_token_survives_render_stage_cleanup_and_is_reused(self): + with tempfile.TemporaryDirectory(prefix="rigos-token-publication-") as temporary: + root = Path(temporary) + state = create_state(root) + authority_runtime = root / "runtime" + token_path = authority_runtime / "xmrig-api-token" + first_stage = authority_runtime / ".render-stage.first" + first_stage.mkdir(parents=True) + + with Environment( + RIGOS_STATE_PATH=str(state), + RIGOS_RUNTIME_PATH=str(first_stage), + RIGOS_XMRIG_API_TOKEN_PATH=str(token_path), + RIGOS_RENDER_SKIP_CHOWN="1", + ): + first = load_source("rigos_runtime_render_publication_first", RENDERER) + self.assertEqual(first.API_TOKEN, token_path) + self.assertEqual(first.main(), 0) + + self.assertTrue(token_path.is_file()) + self.assertEqual(stat.S_IMODE(token_path.stat().st_mode), 0o600) + self.assertFalse((first_stage / "xmrig-api-token").exists()) + token_first = token_path.read_text(encoding="ascii").strip() + private_first = json.loads((first_stage / "xmrig.json").read_text(encoding="utf-8")) + status_first = json.loads( + (first_stage / "runtime-config-status.json").read_text(encoding="utf-8") + ) + self.assertEqual(private_first["http"]["access-token"], token_first) + self.assertEqual(status_first["http_api"]["token_path"], str(token_path)) + + shutil.rmtree(first_stage) + self.assertTrue(token_path.is_file()) + + second_stage = authority_runtime / ".render-stage.second" + second_stage.mkdir() + with Environment( + RIGOS_STATE_PATH=str(state), + RIGOS_RUNTIME_PATH=str(second_stage), + RIGOS_XMRIG_API_TOKEN_PATH=str(token_path), + RIGOS_RENDER_SKIP_CHOWN="1", + ): + second = load_source("rigos_runtime_render_publication_second", RENDERER) + self.assertEqual(second.main(), 0) + + private_second = json.loads((second_stage / "xmrig.json").read_text(encoding="utf-8")) + self.assertEqual(private_second["http"]["access-token"], token_first) + self.assertEqual(token_path.read_text(encoding="ascii").strip(), token_first) + shutil.rmtree(second_stage) + self.assertTrue(token_path.is_file()) + + def test_publisher_wires_authority_token_outside_stage(self): + publisher = PUBLISHER.read_text(encoding="utf-8") + self.assertIn('RIGOS_RUNTIME_PATH="$stage" \\\n', publisher) + self.assertIn( + 'RIGOS_XMRIG_API_TOKEN_PATH="$runtime/xmrig-api-token" \\\n', + publisher, + ) + self.assertIn(' "$renderer"\n', publisher) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/scripts/verify-miner-observer-image.sh b/scripts/verify-miner-observer-image.sh new file mode 100644 index 00000000..ad941e79 --- /dev/null +++ b/scripts/verify-miner-observer-image.sh @@ -0,0 +1,274 @@ +#!/bin/bash +set -euo pipefail + +die() { + printf 'verify-miner-observer-image: %s\n' "$*" >&2 + exit 1 +} + +[[ $# -eq 1 ]] || die 'usage: verify-miner-observer-image.sh ' +image="$(readlink -f "$1")" +[[ -f "$image" ]] || die "image is missing: $image" +[[ "$(id -u)" -eq 0 ]] || die 'must run as root' + +partition_json="$(sfdisk --json "$image")" +start="$(jq -r '.partitiontable.partitions[1].start' <<<"$partition_json")" +size="$(jq -r '.partitiontable.partitions[1].size' <<<"$partition_json")" +[[ "$start" =~ ^[0-9]+$ && "$size" =~ ^[0-9]+$ ]] || die 'ROOT_A geometry is invalid' + +loop="$(losetup --find --show --read-only --offset $((start * 512)) --sizelimit $((size * 512)) "$image")" +temporary="$(mktemp -d)" +cleanup() { + set +e + mountpoint -q "$temporary/root-a" && umount "$temporary/root-a" + losetup -d "$loop" 2>/dev/null + rm -rf "$temporary" +} +trap cleanup EXIT + +mkdir -p "$temporary/root-a" "$temporary/squash" +mount -o ro "$loop" "$temporary/root-a" +squashfs="$temporary/root-a/live/filesystem.squashfs" +[[ -f "$squashfs" ]] || die 'ROOT_A squashfs is missing' + +unsquashfs -no-progress -d "$temporary/squash" "$squashfs" \ + usr/bin/python3 usr/bin/python3.11 \ + usr/lib/rigos/rigos-runtime-render \ + usr/lib/rigos/rigos-runtime-publish \ + usr/lib/rigos/rigos-miner-health \ + etc/systemd/system/rigos-miner-health.service \ + etc/systemd/system/rigos-miner-health.timer \ + etc/systemd/system/rigos-runtime-render.service \ + >/dev/null + +root="$temporary/squash" +renderer="$root/usr/lib/rigos/rigos-runtime-render" +publisher="$root/usr/lib/rigos/rigos-runtime-publish" +observer="$root/usr/lib/rigos/rigos-miner-health" +service="$root/etc/systemd/system/rigos-miner-health.service" +timer="$root/etc/systemd/system/rigos-miner-health.timer" + +[[ -x "$root/usr/bin/python3" ]] || die 'Python runtime is missing' +[[ -x "$renderer" ]] || die 'runtime renderer is missing or not executable' +[[ -x "$publisher" ]] || die 'runtime publisher is missing or not executable' +[[ -x "$observer" ]] || die 'miner observer is missing or not executable' +python3 -m py_compile "$renderer" "$observer" + +python3 - "$observer" <<'PY' +import importlib.machinery +import importlib.util +import sys + +path = sys.argv[1] +loader = importlib.machinery.SourceFileLoader("rigos_image_observer", path) +spec = importlib.util.spec_from_loader("rigos_image_observer", loader) +if spec is None: + raise SystemExit("could not load extracted observer") +module = importlib.util.module_from_spec(spec) +loader.exec_module(module) + +properties = {"ActiveState": "active", "SubState": "running", "MainPID": "123"} + +def classify(summary): + metrics = module.summary_metrics(summary) + return metrics, module.classify( + properties, + "S", + 600, + "r1", + "ready", + "r1", + metrics, + None, + "", + True, + ) + +stale_metrics, stale_state = classify({ + "hashrate": {"total": [0, 340.8, 339.7], "highest": 341.2}, + "connection": { + "pool": "pool.example:1234", + "ip": None, + "uptime": 0, + "uptime_ms": 0, + "failures": 3, + }, +}) +if stale_metrics.get("pool_connected") is not False: + raise SystemExit("extracted observer trusts stale pool name") +if stale_state != ("waiting_external", "pool_or_network_unavailable"): + raise SystemExit(f"extracted observer misclassifies disconnected historical hashrate: {stale_state}") + +historical_metrics, historical_state = classify({ + "hashrate": {"total": [0, 340.9, 340.7], "highest": 342.1}, + "connection": { + "pool": "pool.example:1234", + "ip": "203.0.113.10", + "uptime": 590, + "uptime_ms": 590125, + "failures": 0, + }, +}) +if historical_metrics.get("pool_connected") is not True: + raise SystemExit("extracted observer rejects active pool evidence") +if historical_state != ("degraded", "no_current_hashrate_from_api"): + raise SystemExit(f"extracted observer trusts historical hashrate as current: {historical_state}") + +active_metrics, active_state = classify({ + "hashrate": {"total": [341.2, 340.9, 340.7], "highest": 342.1}, + "connection": { + "pool": "pool.example:1234", + "ip": "203.0.113.10", + "uptime": 590, + "uptime_ms": 590125, + "failures": 0, + }, +}) +if active_metrics.get("pool_connected") is not True: + raise SystemExit("extracted observer rejects active pool evidence") +if active_state != ("ready", None): + raise SystemExit(f"extracted observer rejects active hashrate: {active_state}") + +schema_metrics = module.summary_metrics({ + "uptime": 900.5, + "connection": { + "pool": "pool.example:1234", + "ip": None, + "uptime": 0.1, + "uptime_ms": 0.5, + "accepted": 43.9, + "rejected": 1.2, + "failures": 2.5, + "ping": 109.7, + }, + "results": { + "shares_good": 42.8, + "shares_total": 44.1, + }, + "hugepages": [1168.5, 1169.5], +}) +for key in ( + "uptime_seconds", + "connection_uptime_seconds", + "connection_uptime_ms", + "accepted_shares", + "rejected_shares", + "connection_failures", + "pool_ping_ms", + "hugepages_used", + "hugepages_total", +): + if schema_metrics.get(key) is not None: + raise SystemExit(f"extracted observer truncates fractional counters: {key}") +if schema_metrics.get("pool_connected") is not False: + raise SystemExit("extracted observer trusts fractional uptime as active") + +old_ready_new_external = "\n".join([ + "miner speed 10s/60s/15m 341.2 340.9 340.7 H/s", + "cpu accepted (43/0) diff 10000", + "net connect error: operation timed out", +]) +state = module.journal_fallback_state(old_ready_new_external, 600, "api_unavailable") +if state != ("waiting_external", "pool_or_network_unavailable"): + raise SystemExit(f"extracted observer trusts stale journal ready evidence: {state}") + +old_external_new_ready = "\n".join([ + "net connect error: operation timed out", + "miner speed 10s/60s/15m 341.2 340.9 340.7 H/s", + "cpu accepted (44/0) diff 10000", +]) +state = module.journal_fallback_state(old_external_new_ready, 600, "api_unavailable") +if state != ("ready", None): + raise SystemExit(f"extracted observer ignores newer journal ready evidence: {state}") + +authority_error_state = module.classify( + properties, + "S", + 600, + "r1", + "ready", + "r1", + None, + "api_token_missing", + "cpu accepted (43/0) diff 10000", + True, +) +if authority_error_state != ("degraded", "api_token_missing"): + raise SystemExit(f"extracted observer hides API authority failure: {authority_error_state}") + +transient_state = module.classify( + properties, + "S", + 600, + "r1", + "ready", + "r1", + None, + "api_unavailable", + "cpu accepted (43/0) diff 10000", + True, +) +if transient_state != ("ready", None): + raise SystemExit(f"extracted observer rejects bounded transient fallback: {transient_state}") +PY + +for required in \ + 'API_TOKEN = Path(os.environ.get("RIGOS_XMRIG_API_TOKEN_PATH", str(RUNTIME / "xmrig-api-token")))' \ + 'API_HOST = "127.0.0.1"' \ + 'API_PORT = 18080' \ + 'token = secrets.token_urlsafe(48)' \ + '"access-token": api_token' \ + '"restricted": True' \ + 'http.pop("access-token", None)' \ + '"token_path": str(API_TOKEN)' +do + grep -Fq "$required" "$renderer" || die "runtime API authority contract is missing: $required" +done +if grep -Eq 'API_HOST = "(0\.0\.0\.0|::)"' "$renderer"; then + die 'runtime API authority exposes a non-loopback bind' +fi + +for required in \ + 'RIGOS_RUNTIME_PATH="$stage" \' \ + 'RIGOS_XMRIG_API_TOKEN_PATH="$runtime/xmrig-api-token" \' \ + ' "$renderer"' +do + grep -Fqx "$required" "$publisher" || die "runtime token publication contract is missing: $required" +done + +for required in \ + 'connection.request(' \ + '"/2/summary"' \ + '"Authorization": f"Bearer {token}"' \ + 'API_MAX_BYTES = 256 * 1024' \ + 'def nonnegative_integer(value: object) -> int | None:' \ + 'connection_ip = connection.get("ip")' \ + 'connection_uptime_ms = nonnegative_integer(connection.get("uptime_ms"))' \ + '"pool_connected": pool_connected' \ + 'current_hashrate = api_metrics.get("hashrate_10s")' \ + 'if pool_connected and current_hashrate_positive:' \ + 'return "degraded", "current_hashrate_unavailable"' \ + 'return "degraded", "no_current_hashrate_from_api"' \ + 'if api_error not in (None, "api_unavailable"):' \ + 'def latest_journal_signal(text: str) -> str | None:' \ + '"latest_journal_signal": latest_journal_signal(journal)' \ + '"source": "xmrig_http_api" if metrics is not None else "journal_fallback"' \ + 'return "degraded", api_error or "miner_api_unavailable"' +do + grep -Fq "$required" "$observer" || die "miner observer API contract is missing: $required" +done + +for required in \ + 'ExecStart=/usr/bin/python3 /usr/lib/rigos/rigos-miner-health' \ + 'RestrictAddressFamilies=AF_UNIX AF_INET' \ + 'IPAddressDeny=any' \ + 'IPAddressAllow=127.0.0.0/8' \ + 'ReadWritePaths=/run/rigos' +do + grep -Fqx "$required" "$service" || die "miner observer sandbox contract is missing: $required" +done +grep -Fq 'OnUnitActiveSec=1min' "$timer" || die 'miner observer timer cadence is missing' +grep -Fq 'ExecStart=/usr/lib/rigos/rigos-runtime-authority' "$root/etc/systemd/system/rigos-runtime-render.service" \ + || die 'serialized runtime authority is not wired' + +printf 'RIGOS authenticated XMRig observer image verification passed: %s\n' "$image" diff --git a/scripts/verify-randomx-performance-image.sh b/scripts/verify-randomx-performance-image.sh new file mode 100644 index 00000000..1a774407 --- /dev/null +++ b/scripts/verify-randomx-performance-image.sh @@ -0,0 +1,178 @@ +#!/bin/bash +set -euo pipefail + +die() { + printf 'verify-randomx-performance-image: %s\n' "$*" >&2 + exit 1 +} + +[[ $# -eq 1 ]] || die 'usage: verify-randomx-performance-image.sh ' +image="$(readlink -f "$1")" +[[ -f "$image" ]] || die "image is missing: $image" +[[ "$(id -u)" -eq 0 ]] || die 'must run as root' + +partition_json="$(sfdisk --json "$image")" +start="$(jq -r '.partitiontable.partitions[1].start' <<<"$partition_json")" +size="$(jq -r '.partitiontable.partitions[1].size' <<<"$partition_json")" +[[ "$start" =~ ^[0-9]+$ && "$size" =~ ^[0-9]+$ ]] || die 'ROOT_A geometry is invalid' + +loop="$(losetup --find --show --read-only --offset $((start * 512)) --sizelimit $((size * 512)) "$image")" +temporary="$(mktemp -d)" +cleanup() { + set +e + mountpoint -q "$temporary/root-a" && umount "$temporary/root-a" + losetup -d "$loop" 2>/dev/null + rm -rf "$temporary" +} +trap cleanup EXIT + +mkdir -p "$temporary/root-a" "$temporary/squash" +mount -o ro "$loop" "$temporary/root-a" +squashfs="$temporary/root-a/live/filesystem.squashfs" +[[ -f "$squashfs" ]] || die 'ROOT_A squashfs is missing' + +listing="$temporary/squashfs.list" +unsquashfs -ll "$squashfs" >"$listing" + +if awk '{print $NF}' "$listing" \ + | grep -E '^squashfs-root/etc/ssh/ssh_host_.*_key(\.pub)?$' >/dev/null +then + die 'appliance image contains a baked SSH host key' +fi + +msr_support="missing" +# Do not use grep -q in a pipe while pipefail is enabled. An early grep exit can +# SIGPIPE the producer and turn a real match into a false pipeline failure. +if awk '{print $NF}' "$listing" \ + | grep -E '^squashfs-root/(usr/)?lib/modules/[^/]+/kernel/arch/x86/kernel/msr\.ko(\.(xz|zst|gz))?$' \ + >/dev/null +then + msr_support="module" +else + while IFS= read -r builtin_path; do + if unsquashfs -cat "$squashfs" "$builtin_path" \ + | grep -E '(^|/)kernel/arch/x86/kernel/msr\.ko$' \ + >/dev/null + then + msr_support="builtin" + break + fi + done < <( + awk '{print $NF}' "$listing" \ + | sed -nE 's#^squashfs-root/((usr/)?lib/modules/[^/]+/modules\.builtin)$#\1#p' + ) +fi + +[[ "$msr_support" != "missing" ]] \ + || die 'kernel MSR support is absent from module files and modules.builtin' + +unsquashfs -no-progress -d "$temporary/squash" "$squashfs" \ + usr/bin/python3 usr/bin/python3.11 usr/bin/kmod usr/sbin/modprobe \ + usr/lib/rigos/rigos-randomx-msr usr/lib/rigos/rigos-miner-gate \ + usr/lib/rigos/rigos-ssh-hostkeys \ + etc/ssh/sshd_config.d/01-rigos-hostkeys.conf \ + etc/systemd/system/rigos-randomx-msr.service \ + etc/systemd/system/rigos-ssh-hostkeys.service \ + etc/systemd/system/ssh.service.d/rigos-observe.conf \ + etc/systemd/system/rigos-miner.service \ + etc/systemd/system/rigos-miner.service.d/randomx-msr.conf \ + etc/systemd/system/multi-user.target.wants/rigos-randomx-msr.service \ + etc/systemd/system/multi-user.target.wants/rigos-ssh-hostkeys.service \ + >/dev/null + +root="$temporary/squash" +[[ -x "$root/usr/bin/python3" ]] || die 'Python runtime for authority services is missing' +[[ -x "$root/usr/bin/kmod" ]] || die 'kmod runtime for MSR authority is missing' +[[ -L "$root/usr/sbin/modprobe" || -x "$root/usr/sbin/modprobe" ]] || die 'modprobe entrypoint is missing' +[[ -f "$root/usr/lib/rigos/rigos-randomx-msr" ]] || die 'RandomX MSR authority is missing' +[[ -f "$root/usr/lib/rigos/rigos-miner-gate" ]] || die 'miner safety gate is missing' +[[ -f "$root/usr/lib/rigos/rigos-ssh-hostkeys" ]] || die 'persistent SSH host-key authority is missing' +[[ -L "$root/etc/systemd/system/multi-user.target.wants/rigos-randomx-msr.service" ]] \ + || die 'RandomX MSR authority is not enabled in the appliance' +[[ -L "$root/etc/systemd/system/multi-user.target.wants/rigos-ssh-hostkeys.service" ]] \ + || die 'persistent SSH host-key authority is not enabled in the appliance' +python3 -m py_compile \ + "$root/usr/lib/rigos/rigos-randomx-msr" \ + "$root/usr/lib/rigos/rigos-miner-gate" \ + "$root/usr/lib/rigos/rigos-ssh-hostkeys" + +service="$root/etc/systemd/system/rigos-randomx-msr.service" +dropin="$root/etc/systemd/system/rigos-miner.service.d/randomx-msr.conf" +miner="$root/etc/systemd/system/rigos-miner.service" +authority="$root/usr/lib/rigos/rigos-randomx-msr" +miner_gate="$root/usr/lib/rigos/rigos-miner-gate" +hostkey_service="$root/etc/systemd/system/rigos-ssh-hostkeys.service" +hostkey_authority="$root/usr/lib/rigos/rigos-ssh-hostkeys" +hostkey_policy="$root/etc/ssh/sshd_config.d/01-rigos-hostkeys.conf" +ssh_dropin="$root/etc/systemd/system/ssh.service.d/rigos-observe.conf" + +for required in \ + 'ExecStartPre=-/usr/sbin/modprobe msr' \ + 'ExecStart=/usr/bin/python3 /usr/lib/rigos/rigos-randomx-msr apply' \ + 'ExecStop=/usr/bin/python3 /usr/lib/rigos/rigos-randomx-msr restore' \ + 'CapabilityBoundingSet=CAP_SYS_MODULE CAP_SYS_RAWIO' \ + 'ReadWritePaths=/run/rigos -/dev/cpu' +do + grep -Fqx "$required" "$service" || die "MSR service contract is missing: $required" +done + +grep -Fqx 'Wants=rigos-randomx-msr.service' "$dropin" || die 'miner does not want optional MSR authority' +grep -Fqx 'After=rigos-randomx-msr.service' "$dropin" || die 'miner is not ordered after MSR authority' +if grep -Fq 'Requires=rigos-randomx-msr.service' "$dropin"; then + die 'optional MSR optimization incorrectly blocks the baseline miner path' +fi +grep -Fqx 'User=rigos' "$miner" || die 'miner no longer runs as the unprivileged rigos user' +grep -Fqx 'ExecCondition=/usr/lib/rigos/rigos-miner-gate' "$miner" \ + || die 'miner safety gate is not wired' + +for required in \ + 'After=rigos-state-ready.service' \ + 'Requires=rigos-state-ready.service' \ + 'Before=ssh.service' \ + 'ExecStart=/usr/lib/rigos/rigos-ssh-hostkeys' \ + 'ReadWritePaths=/var/lib/rigos /run/rigos' +do + grep -Fqx "$required" "$hostkey_service" \ + || die "persistent SSH host-key service contract is missing: $required" +done +grep -Fqx 'HostKey /var/lib/rigos/system/ssh-hostkeys/ssh_host_ed25519_key' "$hostkey_policy" \ + || die 'sshd does not use the persistent RIGOS host identity' +grep -Fqx 'Requires=rigos-ssh-hostkeys.service' "$ssh_dropin" \ + || die 'ssh.service does not require persistent host identity' +grep -Fqx 'After=rigos-recovery-access.service rigos-ssh-hostkeys.service' "$ssh_dropin" \ + || die 'ssh.service ordering bypasses persistent host identity' +for required in \ + 'STATE = Path("/var/lib/rigos")' \ + 'KEYS = SYSTEM / "ssh-hostkeys"' \ + '"schema": "rigos.ssh-hostkeys/v1"' \ + 'os.rename(temporary, KEYS)' \ + '"persistent SSH host identity exists without a valid manifest"' +do + grep -Fq "$required" "$hostkey_authority" \ + || die "persistent SSH host-key authority contract is missing: $required" +done + +for required in \ + 'SUPPORTED_CPUS = {("GenuineIntel", 6, 42)}' \ + 'REGISTER = 0x1A4' \ + 'TARGET_VALUE = 0xF' \ + 'apply_failed_rolled_back' \ + 'apply_failed_rollback_incomplete' \ + 'stale_state_discarded' +do + grep -Fq "$required" "$authority" || die "MSR authority contract is missing: $required" +done + +for required in \ + 'PRODUCTION_STATE = Path("/var/lib/rigos")' \ + 'validate_msr_authority' \ + 'randomx_msr_status_stale' \ + 'randomx_msr_restore_state_missing' \ + 'randomx_msr_authority_unsafe' +do + grep -Fq "$required" "$miner_gate" || die "miner MSR safety contract is missing: $required" +done + +printf 'RIGOS RandomX kernel MSR support: %s\n' "$msr_support" +printf 'RIGOS persistent SSH host-key image verification passed\n' +printf 'RIGOS RandomX performance image verification passed: %s\n' "$image" diff --git a/scripts/verify-runtime-dependencies.sh b/scripts/verify-runtime-dependencies.sh new file mode 100644 index 00000000..8e5ad0ff --- /dev/null +++ b/scripts/verify-runtime-dependencies.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -euo pipefail + +target=${1:-build/usb/includes.chroot} +if [[ ! -d "$target" ]]; then + echo "runtime dependency scan target is missing: $target" >&2 + exit 66 +fi + +pattern='(^|[^[:alnum:]_])(curl|wget|Invoke-WebRequest|latest)([^[:alnum:]_]|$)' + +set +e +rg -n -i -- "$pattern" "$target" +status=$? +set -e + +case "$status" in + 0) + echo "runtime miner download or floating dependency detected" >&2 + exit 1 + ;; + 1) + exit 0 + ;; + *) + echo "runtime dependency scan failed: exit $status" >&2 + exit "$status" + ;; +esac diff --git a/scripts/verify-systemd-ordering.py b/scripts/verify-systemd-ordering.py index a5c69eac..a6f84438 100644 --- a/scripts/verify-systemd-ordering.py +++ b/scripts/verify-systemd-ordering.py @@ -43,8 +43,9 @@ def includes(actual, expected, message): def verify(units): names = { "rigos-state.service", "rigos-recovery-access.service", - "rigos-state-ready.service", "rigos-profile-apply.service", - "rigos-firstboot.service", "rigos-hugepages.service", "rigos-miner.service", + "rigos-state-ready.service", "rigos-ssh-hostkeys.service", + "rigos-profile-apply.service", "rigos-firstboot.service", + "rigos-hugepages.service", "rigos-miner.service", } includes(set(units), names, "RIGOS unit set is incomplete") state = units["rigos-state.service"] @@ -60,11 +61,29 @@ def verify(units): require(ready.scalar("Unit", "DefaultDependencies") != "no", "state-ready must use normal dependencies") includes(ready.words("Unit", "After"), {"rigos-state.service", "rigos-recovery-access.service"}, "state-ready ordering is incomplete") includes(ready.words("Unit", "Requires"), {"rigos-state.service"}, "state-ready must require state") - includes(ready.words("Unit", "Before"), {"rigos-profile-apply.service", "rigos-firstboot.service", "rigos-hugepages.service", "rigos-miner.service"}, "state-ready downstream ordering is incomplete") + includes( + ready.words("Unit", "Before"), + { + "rigos-ssh-hostkeys.service", "rigos-profile-apply.service", + "rigos-firstboot.service", "rigos-hugepages.service", + "rigos-miner.service", + }, + "state-ready downstream ordering is incomplete", + ) require("local-fs.target" not in ready.words("Unit", "Before"), "state-ready must not order before local-fs") require("local-fs.target" not in ready.words("Install", "WantedBy"), "state-ready must not be installed under local-fs") includes(ready.words("Install", "WantedBy"), {"multi-user.target"}, "state-ready must be installed under multi-user") + hostkeys = units["rigos-ssh-hostkeys.service"] + includes(hostkeys.words("Unit", "After"), {"rigos-state-ready.service"}, "SSH host-key authority must follow state readiness") + includes(hostkeys.words("Unit", "Requires"), {"rigos-state-ready.service"}, "SSH host-key authority must require state readiness") + includes(hostkeys.words("Unit", "Before"), {"ssh.service"}, "SSH host-key authority must precede sshd") + require( + hostkeys.scalar("Service", "ExecStart") == "/usr/lib/rigos/rigos-ssh-hostkeys", + "SSH host-key authority entrypoint is not exact", + ) + includes(hostkeys.words("Install", "WantedBy"), {"multi-user.target"}, "SSH host-key authority must be enabled under multi-user") + firstboot = units["rigos-firstboot.service"] includes(firstboot.words("Unit", "Requires"), {"rigos-state-ready.service"}, "firstboot must require state-ready") require(firstboot.scalar("Service", "TTYVHangup") != "yes", "firstboot must not hang up tty1") @@ -90,6 +109,7 @@ def graph_for(units): def cycle_in(graph): state, stack, positions = {}, [], {} + def visit(node): state[node] = 1 positions[node] = len(stack) @@ -105,6 +125,7 @@ def visit(node): positions.pop(node) state[node] = 2 return None + for node in sorted(graph): if state.get(node, 0) == 0: cycle = visit(node) diff --git a/scripts/verify-usb-appliance.sh b/scripts/verify-usb-appliance.sh index 6fab56fe..f89df2bf 100755 --- a/scripts/verify-usb-appliance.sh +++ b/scripts/verify-usb-appliance.sh @@ -68,17 +68,29 @@ mount -o ro "$p3" "$temporary/b" [[ "$(sha256sum "$p3" | cut -d' ' -f1)" == "$(jq -r .root_b_sha256 "$manifest")" ]] || die 'ROOT_B hash mismatch' cmp "$temporary/a/live/filesystem.squashfs" "$temporary/b/live/filesystem.squashfs" cmp "$temporary/a/image-layout.json" "$temporary/b/image-layout.json" -[[ "$(sha256sum "$temporary/a/live/filesystem.squashfs" | cut -d' ' -f1)" == "$(jq -r .root_payload_sha256 "$manifest")" ]] || die 'root payload hash mismatch' +squashfs="$temporary/a/live/filesystem.squashfs" +[[ "$(sha256sum "$squashfs" | cut -d' ' -f1)" == "$(jq -r .root_payload_sha256 "$manifest")" ]] || die 'root payload hash mismatch' [[ "$(jq -r .schema "$temporary/a/image-layout.json")" == 'rigos.image-layout/v2' ]] || die 'embedded layout schema mismatch' [[ "$(jq -r .partition_table "$temporary/a/image-layout.json")" == mbr ]] || die 'embedded layout table mismatch' [[ "$(jq -r .final_state_partition "$temporary/a/image-layout.json")" == 4 ]] || die 'final state partition mismatch' [[ "$(jq -r '.partitions[-1].label' "$temporary/a/image-layout.json")" == RIGOS_STATE_SEED ]] || die 'state seed is not final' -unsquashfs -no-progress -d "$temporary/root" "$temporary/a/live/filesystem.squashfs" \ + +listing="$temporary/squashfs.list" +unsquashfs -ll "$squashfs" >"$listing" +if awk '{print $NF}' "$listing" | grep -E '^squashfs-root/etc/ssh/ssh_host_.*_key(\.pub)?$' >/dev/null; then + die 'appliance image contains a baked SSH host key' +fi + +unsquashfs -no-progress -d "$temporary/root" "$squashfs" \ etc/rigos-release etc/os-release \ + etc/ssh/sshd_config.d/01-rigos-hostkeys.conf \ usr/lib/tmpfiles.d/rigos.conf \ etc/systemd/system/rigos-state.service \ etc/systemd/system/rigos-state-ready.service \ etc/systemd/system/rigos-recovery-access.service \ + etc/systemd/system/rigos-ssh-hostkeys.service \ + etc/systemd/system/ssh.service.d/rigos-observe.conf \ + etc/systemd/system/multi-user.target.wants/rigos-ssh-hostkeys.service \ etc/systemd/system/rigos-firstboot.service \ etc/systemd/system/rigos-hugepages.service \ etc/systemd/system/rigos-miner.service \ @@ -88,20 +100,23 @@ unsquashfs -no-progress -d "$temporary/root" "$temporary/a/live/filesystem.squas etc/systemd/system/rigos-miner-health.timer \ etc/systemd/system/rigos-runtime-render.service \ etc/systemd/system/rigos-profile-apply.service \ - usr/bin/jq usr/bin/python3 usr/bin/python3.11 \ + usr/bin/jq usr/bin/python3 usr/bin/python3.11 usr/bin/findmnt usr/bin/ssh-keygen \ usr/lib/rigos/rigosd usr/lib/rigos/rigosctl \ - usr/lib/rigos/lsblk-compat usr/lib/rigos/rigos-state-init usr/lib/rigos/rigos-state-ready usr/lib/rigos/rigos-config usr/lib/rigos/rigos-performance usr/lib/rigos/rigos-lifecycle-cycles usr/lib/rigos/rigos-miner-gate usr/lib/rigos/rigos-miner-health usr/lib/rigos/rigos-runtime-render usr/lib/rigos/rigos-runtime-publish usr/lib/rigos/rigos-runtime-authority usr/lib/rigos/rigos-runtime-gate usr/lib/rigos/xmrig \ + usr/lib/rigos/lsblk-compat usr/lib/rigos/rigos-state-init usr/lib/rigos/rigos-state-ready usr/lib/rigos/rigos-config usr/lib/rigos/rigos-performance usr/lib/rigos/rigos-lifecycle-cycles usr/lib/rigos/rigos-miner-gate usr/lib/rigos/rigos-miner-health usr/lib/rigos/rigos-runtime-render usr/lib/rigos/rigos-runtime-publish usr/lib/rigos/rigos-runtime-authority usr/lib/rigos/rigos-runtime-gate usr/lib/rigos/rigos-ssh-hostkeys usr/lib/rigos/xmrig \ usr/local/bin/rigosd usr/local/bin/rigosctl \ usr/local/sbin/rigosctl usr/local/sbin/rigos-firstboot usr/local/sbin/rigos-recovery-access usr/local/sbin/rigos-state-orchestrate \ usr/share/rigos >/dev/null grep -Fqx "VERSION_ID=\"$image_version\"" "$temporary/root/etc/rigos-release" || die 'embedded release version mismatch' grep -q 'NAME="RIGOS"' "$temporary/root/etc/os-release" || die 'embedded OS identity mismatch' [[ -x "$temporary/root/usr/bin/jq" ]] || die 'jq runtime dependency is missing from the appliance' +[[ -x "$temporary/root/usr/bin/findmnt" ]] || die 'findmnt runtime dependency is missing from the appliance' +[[ -x "$temporary/root/usr/bin/ssh-keygen" ]] || die 'ssh-keygen runtime dependency is missing from the appliance' python3 -m py_compile "$temporary/root/usr/local/sbin/rigos-firstboot" python3 -m py_compile "$temporary/root/usr/local/sbin/rigos-recovery-access" python3 -m py_compile "$temporary/root/usr/local/sbin/rigos-state-orchestrate" python3 -m py_compile "$temporary/root/usr/lib/rigos/rigos-miner-gate" python3 -m py_compile "$temporary/root/usr/lib/rigos/rigos-miner-health" +python3 -m py_compile "$temporary/root/usr/lib/rigos/rigos-ssh-hostkeys" sh -n "$temporary/root/usr/lib/rigos/rigos-runtime-publish" sh -n "$temporary/root/usr/lib/rigos/rigos-runtime-authority" python3 "$script_dir/verify-systemd-ordering.py" "$temporary/root/etc/systemd/system" @@ -121,6 +136,35 @@ if strings "$temporary/root/usr/lib/rigos/rigos-state-init" | grep -F '/run/rigo grep -Fq 'ExecStart=/usr/lib/rigos/rigos-state-ready' "$temporary/root/etc/systemd/system/rigos-state-ready.service" || die 'state readiness verifier is not wired' if grep -Fq 'Wants=rigos-recovery-access.service' "$temporary/root/etc/systemd/system/rigos-state-ready.service"; then die 'state readiness retriggers interactive recovery access'; fi grep -Fq 'Requires=rigos-state-ready.service' "$temporary/root/etc/systemd/system/rigos-profile-apply.service" || die 'profile apply bypasses state readiness' + +hostkey_service="$temporary/root/etc/systemd/system/rigos-ssh-hostkeys.service" +hostkey_policy="$temporary/root/etc/ssh/sshd_config.d/01-rigos-hostkeys.conf" +ssh_dropin="$temporary/root/etc/systemd/system/ssh.service.d/rigos-observe.conf" +hostkey_authority="$temporary/root/usr/lib/rigos/rigos-ssh-hostkeys" +[[ -x "$hostkey_authority" ]] || die 'persistent SSH host-key authority is missing or not executable' +[[ -L "$temporary/root/etc/systemd/system/multi-user.target.wants/rigos-ssh-hostkeys.service" ]] || die 'persistent SSH host-key service is not enabled' +for required in \ + 'After=rigos-state-ready.service' \ + 'Requires=rigos-state-ready.service' \ + 'Before=ssh.service' \ + 'ExecStart=/usr/lib/rigos/rigos-ssh-hostkeys' \ + 'ReadWritePaths=/var/lib/rigos /run/rigos' +do + grep -Fqx "$required" "$hostkey_service" || die "persistent SSH host-key service contract is missing: $required" +done +grep -Fqx 'HostKey /var/lib/rigos/system/ssh-hostkeys/ssh_host_ed25519_key' "$hostkey_policy" || die 'sshd persistent HostKey policy is missing' +grep -Fqx 'Requires=rigos-ssh-hostkeys.service' "$ssh_dropin" || die 'ssh.service does not require persistent host identity' +grep -Fqx 'After=rigos-recovery-access.service rigos-ssh-hostkeys.service' "$ssh_dropin" || die 'ssh.service ordering bypasses persistent host identity' +for required in \ + 'STATE = Path("/var/lib/rigos")' \ + 'KEYS = SYSTEM / "ssh-hostkeys"' \ + '"schema": "rigos.ssh-hostkeys/v1"' \ + 'os.rename(temporary, KEYS)' \ + '"persistent SSH host identity exists without a valid manifest"' +do + grep -Fq "$required" "$hostkey_authority" || die "persistent SSH host-key authority contract is missing: $required" +done + [[ -x "$temporary/root/usr/lib/rigos/rigos-performance" ]] || die 'performance authority is missing or not executable' grep -Fq 'After=rigos-state-ready.service rigos-profile-apply.service' "$temporary/root/etc/systemd/system/rigos-hugepages.service" || die 'huge page authority ordering is missing' grep -Fq 'Before=rigos-miner.service' "$temporary/root/etc/systemd/system/rigos-hugepages.service" || die 'huge page authority is not ordered before miner' diff --git a/scripts/verify-wsl-entrypoint.sh b/scripts/verify-wsl-entrypoint.sh new file mode 100644 index 00000000..55090d9f --- /dev/null +++ b/scripts/verify-wsl-entrypoint.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -ne 1 ]]; then + echo "usage: verify-wsl-entrypoint.sh REPOSITORY" >&2 + exit 64 +fi + +repo="$1" +if [[ ! -f "$repo/Cargo.toml" || ! -f "$repo/scripts/verify.sh" ]]; then + echo "RIGOS_WSL_REPOSITORY_INVALID=$repo" >&2 + exit 66 +fi + +cd "$repo" + +if [[ -f "$HOME/.cargo/env" ]]; then + # shellcheck disable=SC1091 + . "$HOME/.cargo/env" +fi + +missing=0 +for tool in cargo rustc python3 bash sh git grep rg mktemp; do + if ! command -v "$tool" >/dev/null 2>&1; then + printf 'RIGOS_WSL_TOOL_MISSING=%s\n' "$tool" >&2 + missing=1 + fi +done + +if [[ "$missing" -ne 0 ]]; then + echo "Install the missing tool inside this WSL distribution, then rerun scripts/verify-wsl.ps1." >&2 + exit 127 +fi + +for component in fmt clippy; do + if ! cargo "$component" --version >/dev/null 2>&1; then + printf 'RIGOS_WSL_CARGO_COMPONENT_MISSING=%s\n' "$component" >&2 + missing=1 + fi +done + +if [[ "$missing" -ne 0 ]]; then + echo "Install the missing Rust component inside this WSL distribution, then rerun scripts/verify-wsl.ps1." >&2 + exit 127 +fi + +pycache_root=$(mktemp -d) +cleanup() { + rm -rf "$pycache_root" +} +trap cleanup EXIT HUP INT TERM +export PYTHONPYCACHEPREFIX="$pycache_root" +export PYTHONDONTWRITEBYTECODE=1 + +printf 'RIGOS_WSL_REPO=%s\n' "$repo" +printf 'RIGOS_WSL_CARGO=%s\n' "$(command -v cargo)" +bash ./scripts/verify.sh diff --git a/scripts/verify-wsl.ps1 b/scripts/verify-wsl.ps1 new file mode 100644 index 00000000..aa947abd --- /dev/null +++ b/scripts/verify-wsl.ps1 @@ -0,0 +1,60 @@ +[CmdletBinding()] +param( + [string]$Repository, + [string]$Distribution = $env:RIGOS_WSL_DISTRO +) + +$ErrorActionPreference = "Stop" +$PSNativeCommandUseErrorActionPreference = $false + +if ([string]::IsNullOrWhiteSpace($Repository)) { + if ([string]::IsNullOrWhiteSpace($PSScriptRoot)) { + throw "RIGOS_WSL_SCRIPT_ROOT_UNAVAILABLE" + } + $Repository = Split-Path -Parent $PSScriptRoot +} + +$WslPrefix = @() +if (-not [string]::IsNullOrWhiteSpace($Distribution)) { + $WslPrefix += @("-d", $Distribution) +} + +$Repository = (Resolve-Path -LiteralPath $Repository -ErrorAction Stop).Path +$RepositoryForWsl = $Repository.Replace([char]92, [char]47) + +$SavedErrorActionPreference = $ErrorActionPreference +$ErrorActionPreference = "Continue" +try { + $LinuxRepoOutput = & wsl.exe @WslPrefix -- wslpath -a -- $RepositoryForWsl 2>&1 + $PathExitCode = $LASTEXITCODE +} finally { + $ErrorActionPreference = $SavedErrorActionPreference +} + +$LinuxRepoLines = @($LinuxRepoOutput | ForEach-Object { $_.ToString() }) +if ($PathExitCode -ne 0) { + throw "WSL_PATH_CONVERSION_FAILED: $($LinuxRepoLines -join ' | ')" +} + +$LinuxRepo = ($LinuxRepoLines | Select-Object -Last 1).Trim() +if ([string]::IsNullOrWhiteSpace($LinuxRepo)) { + throw "WSL_PATH_CONVERSION_EMPTY" +} + +$LinuxEntrypoint = "$LinuxRepo/scripts/verify-wsl-entrypoint.sh" + +$SavedErrorActionPreference = $ErrorActionPreference +$ErrorActionPreference = "Continue" +try { + & wsl.exe @WslPrefix -- bash $LinuxEntrypoint $LinuxRepo 2>&1 | + ForEach-Object { Write-Host $_ } + $ExitCode = $LASTEXITCODE +} finally { + $ErrorActionPreference = $SavedErrorActionPreference +} + +if ($ExitCode -ne 0) { + throw "RIGOS_WSL_SOURCE_GATE_FAILED: exit $ExitCode" +} + +Write-Host "RIGOS_WSL_SOURCE_GATE=PASS" diff --git a/scripts/verify.sh b/scripts/verify.sh index 5fae12cc..38baa2cf 100755 --- a/scripts/verify.sh +++ b/scripts/verify.sh @@ -293,10 +293,7 @@ if rg -n 'if\s+.*(moneroocean|2miners|nicehash|supportxmr|herominers|hashvault|n echo "pool-name conditional leaked into runtime core" >&2 exit 1 fi -if rg -n 'curl|wget|Invoke-WebRequest|latest' build/usb/includes.chroot; then - echo "runtime miner download or floating dependency detected" >&2 - exit 1 -fi +bash scripts/verify-runtime-dependencies.sh build/usb/includes.chroot for required in build/usb/THIRD_PARTY_NOTICES docs/miner-provenance.md docs/third-party-components.md; do [[ -s "$required" ]] || { echo "missing third-party disclosure: $required" >&2; exit 1; } done