diff --git a/.gitattributes b/.gitattributes index f210b0cb..b2ec61b1 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,7 +1,19 @@ * text=auto *.sh text eol=lf +*.py text eol=lf *.rs text eol=lf *.toml text eol=lf *.json text eol=lf *.md text eol=lf +*.service text eol=lf +*.timer text eol=lf +*.conf text eol=lf *.ps1 text eol=crlf + +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-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 diff --git a/build/usb/hooks/010-rigos.chroot b/build/usb/hooks/010-rigos.chroot index f7e37ac5..a29b316f 100644 --- a/build/usb/hooks/010-rigos.chroot +++ b/build/usb/hooks/010-rigos.chroot @@ -6,12 +6,10 @@ 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 -m 0755 /usr/lib/rigos /usr/local/bin -ln -sfn /usr/lib/rigos/rigosd /usr/local/bin/rigosd -ln -sfn /usr/lib/rigos/rigosctl /usr/local/bin/rigosctl systemd-tmpfiles --create /usr/lib/tmpfiles.d/rigos.conf -chmod 0755 /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-runtime-render /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 tmp.mount +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 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/systemd/system/rigos-miner-health.service b/build/usb/includes.chroot/etc/systemd/system/rigos-miner-health.service new file mode 100644 index 00000000..bd3523ea --- /dev/null +++ b/build/usb/includes.chroot/etc/systemd/system/rigos-miner-health.service @@ -0,0 +1,18 @@ +[Unit] +Description=Observe RIGOS miner health truth +After=rigos-state-ready.service rigos-runtime-render.service rigos-miner.service + +[Service] +Type=oneshot +ExecStart=/usr/bin/python3 /usr/lib/rigos/rigos-miner-health +NoNewPrivileges=yes +PrivateTmp=yes +ProtectHome=yes +ProtectSystem=strict +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectControlGroups=yes +RestrictAddressFamilies=AF_UNIX +RestrictNamespaces=yes +LockPersonality=yes +ReadWritePaths=/run/rigos diff --git a/build/usb/includes.chroot/etc/systemd/system/rigos-miner-health.timer b/build/usb/includes.chroot/etc/systemd/system/rigos-miner-health.timer new file mode 100644 index 00000000..97de0c73 --- /dev/null +++ b/build/usb/includes.chroot/etc/systemd/system/rigos-miner-health.timer @@ -0,0 +1,11 @@ +[Unit] +Description=Sample RIGOS miner health truth + +[Timer] +OnBootSec=2min +OnUnitActiveSec=1min +AccuracySec=5s +Unit=rigos-miner-health.service + +[Install] +WantedBy=timers.target diff --git a/build/usb/includes.chroot/etc/systemd/system/rigos-miner.service.d/runtime-render.conf b/build/usb/includes.chroot/etc/systemd/system/rigos-miner.service.d/runtime-render.conf index 030eecfd..3b94d5ff 100644 --- a/build/usb/includes.chroot/etc/systemd/system/rigos-miner.service.d/runtime-render.conf +++ b/build/usb/includes.chroot/etc/systemd/system/rigos-miner.service.d/runtime-render.conf @@ -5,7 +5,7 @@ ConditionPathExists= ConditionPathExists=/var/lib/rigos/current [Service] -ExecCondition=+/usr/lib/rigos/rigos-runtime-render +ExecCondition=+/usr/lib/rigos/rigos-runtime-authority ExecCondition=/usr/lib/rigos/rigos-runtime-gate ExecStart= -ExecStart=/usr/lib/rigos/xmrig --config=/run/rigos/xmrig.json +ExecStart=/usr/lib/rigos/xmrig -c /run/rigos/xmrig.json diff --git a/build/usb/includes.chroot/etc/systemd/system/rigos-miner.service.d/stability.conf b/build/usb/includes.chroot/etc/systemd/system/rigos-miner.service.d/stability.conf new file mode 100644 index 00000000..5bf95881 --- /dev/null +++ b/build/usb/includes.chroot/etc/systemd/system/rigos-miner.service.d/stability.conf @@ -0,0 +1,17 @@ +[Unit] +StartLimitIntervalSec=10min +StartLimitBurst=5 + +[Service] +Restart=on-failure +RestartSec=15s +TimeoutStartSec=2min +TimeoutStopSec=30s +KillSignal=SIGINT +FinalKillSignal=SIGKILL +SendSIGKILL=yes +SuccessExitStatus=SIGINT SIGTERM +OOMPolicy=stop +OOMScoreAdjust=-500 +TasksMax=64 +LimitNOFILE=4096 diff --git a/build/usb/includes.chroot/etc/systemd/system/rigos-runtime-render.service b/build/usb/includes.chroot/etc/systemd/system/rigos-runtime-render.service index 166dfc49..cbf8f72a 100644 --- a/build/usb/includes.chroot/etc/systemd/system/rigos-runtime-render.service +++ b/build/usb/includes.chroot/etc/systemd/system/rigos-runtime-render.service @@ -1,5 +1,5 @@ [Unit] -Description=Render RIGOS runtime miner configuration +Description=Render and publish RIGOS runtime miner configuration After=rigos-state-ready.service rigos-profile-apply.service Requires=rigos-state-ready.service rigos-profile-apply.service Before=rigos-hugepages.service rigos-miner.service @@ -7,7 +7,7 @@ ConditionPathExists=/var/lib/rigos/current [Service] Type=oneshot -ExecStart=/usr/lib/rigos/rigos-runtime-render +ExecStart=/usr/lib/rigos/rigos-runtime-authority NoNewPrivileges=yes PrivateTmp=yes ProtectHome=yes diff --git a/build/usb/includes.chroot/usr/lib/rigos/rigos-miner-health b/build/usb/includes.chroot/usr/lib/rigos/rigos-miner-health new file mode 100644 index 00000000..39221147 --- /dev/null +++ b/build/usb/includes.chroot/usr/lib/rigos/rigos-miner-health @@ -0,0 +1,268 @@ +#!/usr/bin/python3 +import datetime as dt +import json +import os +import re +import subprocess +import sys +import uuid +from pathlib import Path + +RUNTIME = Path(os.environ.get("RIGOS_RUNTIME_PATH", "/run/rigos")) +BOOT_ID = Path(os.environ.get("RIGOS_BOOT_ID_PATH", "/proc/sys/kernel/random/boot_id")) +CURRENT = Path(os.environ.get("RIGOS_CURRENT_REVISION_PATH", "/var/lib/rigos/current")) +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" +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 + +EXTERNAL_MARKERS = ( + "connect error", + "connection refused", + "connection reset", + "network error", + "dns error", + "no active pools", + "operation timed out", + "connection timeout", +) +SHARE_RE = re.compile(r"accepted \((\d+)/(\d+)\)") + + +def atomic_json(path: Path, value: dict) -> 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, 0o644) + 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) + try: + os.fsync(directory) + finally: + os.close(directory) + finally: + try: + temporary.unlink() + except FileNotFoundError: + pass + + +def command(argv: list[str]) -> subprocess.CompletedProcess[str]: + return subprocess.run( + argv, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=10, + check=False, + ) + + +def systemd_properties() -> dict[str, str]: + result = command([ + SYSTEMCTL, + "show", + "rigos-miner.service", + "--property=ActiveState", + "--property=SubState", + "--property=MainPID", + "--property=NRestarts", + "--property=Result", + "--property=ExecMainStatus", + "--property=ActiveEnterTimestampMonotonic", + "--no-pager", + ]) + if result.returncode != 0: + raise RuntimeError("systemctl_show_failed") + values = {} + for line in result.stdout.splitlines(): + if "=" in line: + key, value = line.split("=", 1) + values[key] = value + return values + + +def current_revision() -> str | None: + try: + return CURRENT.resolve(strict=True).name + except OSError: + return None + + +def runtime_revision() -> tuple[str | None, str | None]: + try: + value = json.loads((RUNTIME / "runtime-config-status.json").read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError): + return None, None + if not isinstance(value, dict): + return None, None + return value.get("outcome"), value.get("revision") + + +def process_state(pid: int) -> str | None: + try: + raw = (PROC / str(pid) / "stat").read_text(encoding="ascii") + except (OSError, UnicodeError): + return None + end = raw.rfind(")") + if end < 0: + return None + fields = raw[end + 1 :].split() + return fields[0] if fields else None + + +def active_seconds(properties: dict[str, str]) -> int | None: + try: + entered = int(properties.get("ActiveEnterTimestampMonotonic", "0")) + uptime = float((PROC / "uptime").read_text(encoding="ascii").split()[0]) + except (OSError, UnicodeError, ValueError, IndexError): + return None + if entered <= 0: + return None + return max(0, int(uptime - entered / 1_000_000)) + + +def recent_journal() -> tuple[str, bool]: + result = command([ + JOURNALCTL, + "-b", + "-u", + "rigos-miner.service", + "--since", + f"-{WINDOW_SECONDS} seconds", + "--lines", + str(MAX_JOURNAL_LINES), + "--no-pager", + "-o", + "cat", + ]) + return result.stdout, result.returncode == 0 + + +def last_share_counts(text: str) -> tuple[int | None, int | None]: + matches = SHARE_RE.findall(text) + if not matches: + return None, None + accepted, rejected = matches[-1] + return int(accepted), int(rejected) + + +def classify( + properties: dict[str, str], + proc_state: str | None, + active_for: int | None, + canonical_revision: str | None, + rendered_outcome: str | None, + rendered_revision: str | None, + journal: str, + journal_available: bool, +) -> tuple[str, str | None]: + if properties.get("ActiveState") != "active" or properties.get("SubState") != "running": + return "failed", "miner_unit_not_running" + try: + pid = int(properties.get("MainPID", "0")) + except ValueError: + pid = 0 + if pid <= 0 or proc_state is None or proc_state in ("X", "Z"): + return "failed", "miner_process_unavailable" + if canonical_revision is None: + 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" + + +def main() -> int: + try: + boot_id = BOOT_ID.read_text(encoding="ascii").strip() + if not boot_id: + raise RuntimeError("boot_id_unavailable") + properties = systemd_properties() + try: + pid = int(properties.get("MainPID", "0")) + except ValueError: + pid = 0 + proc_state = process_state(pid) if pid > 0 else None + active_for = active_seconds(properties) + canonical = current_revision() + rendered_outcome, rendered_revision = runtime_revision() + journal, journal_available = recent_journal() + accepted, rejected = last_share_counts(journal) + state, reason = classify( + properties, + proc_state, + active_for, + canonical, + rendered_outcome, + rendered_revision, + journal, + journal_available, + ) + lowered_journal = journal.lower() + value = { + "schema": "rigos.miner-health-status/v1", + "generated_at": dt.datetime.now(dt.timezone.utc).isoformat(timespec="milliseconds"), + "boot_id": boot_id, + "state": state, + "reason": reason, + "unit": { + "active_state": properties.get("ActiveState"), + "sub_state": properties.get("SubState"), + "result": properties.get("Result"), + "main_pid": pid or None, + "process_state": proc_state, + "restart_count": int(properties.get("NRestarts", "0") or 0), + "exec_main_status": int(properties.get("ExecMainStatus", "0") or 0), + "active_seconds": active_for, + }, + "config": { + "current_revision": canonical, + "runtime_outcome": rendered_outcome, + "runtime_revision": rendered_revision, + }, + "evidence": { + "window_seconds": WINDOW_SECONDS, + "journal_available": journal_available, + "journal_line_limit": MAX_JOURNAL_LINES, + "recent_speed": "miner speed" in lowered_journal, + "external_wait_marker": any( + marker in lowered_journal for marker in EXTERNAL_MARKERS + ), + "accepted_shares": accepted, + "rejected_shares": rejected, + }, + "remediation": "observe_only", + } + atomic_json(STATUS, value) + print(json.dumps({ + "schema": "rigos.miner-health-observer/v1", + "state": state, + "reason": reason, + "remediation": "observe_only", + }, sort_keys=True)) + return 0 + except (OSError, UnicodeError, RuntimeError, subprocess.SubprocessError, ValueError) as error: + print(f"rigos-miner-health: {error}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/build/usb/includes.chroot/usr/lib/rigos/rigos-runtime-authority b/build/usb/includes.chroot/usr/lib/rigos/rigos-runtime-authority new file mode 100755 index 00000000..aa09747b --- /dev/null +++ b/build/usb/includes.chroot/usr/lib/rigos/rigos-runtime-authority @@ -0,0 +1,32 @@ +#!/bin/sh +# Repository contract: LF line endings are required for WSL and appliance execution. +set -eu + +runtime=${RIGOS_RUNTIME_PATH:-/run/rigos} +publisher=${RIGOS_RUNTIME_PUBLISHER:-/usr/lib/rigos/rigos-runtime-publish} +mkdir -p "$runtime" +chmod 0755 "$runtime" + +lock="$runtime/.runtime-publish.lock" +exec 9>"$lock" +chmod 0600 "$lock" +if ! flock -x -w 30 9; then + echo 'rigos-runtime-authority: publication lock timeout' >&2 + exit 75 +fi + +cleanup_stale() { + find -P "$runtime" -mindepth 1 -maxdepth 1 -type d \ + -name '.render-stage.*' -exec rm -rf -- {} + + find -P "$runtime" -mindepth 1 -maxdepth 1 -type f \ + \( -name '.xmrig-public.*' \ + -o -name '.runtime-status.*' \ + -o -name '.xmrig-runtime.*' \) \ + -delete +} + +cleanup_stale +status=0 +"$publisher" || status=$? +cleanup_stale +exit "$status" diff --git a/build/usb/includes.chroot/usr/lib/rigos/rigos-runtime-publish b/build/usb/includes.chroot/usr/lib/rigos/rigos-runtime-publish new file mode 100644 index 00000000..c0aa5740 --- /dev/null +++ b/build/usb/includes.chroot/usr/lib/rigos/rigos-runtime-publish @@ -0,0 +1,113 @@ +#!/bin/sh +# Repository contract: LF line endings are required for WSL and appliance execution. +set -eu + +runtime=${RIGOS_RUNTIME_PATH:-/run/rigos} +renderer=${RIGOS_RUNTIME_RENDERER:-/usr/lib/rigos/rigos-runtime-render} +jq_bin=${RIGOS_JQ:-/usr/bin/jq} +if [ ! -x "$jq_bin" ]; then + echo "rigos-runtime-publish: required jq runtime is missing: $jq_bin" >&2 + exit 127 +fi +mkdir -p "$runtime" +chmod 0755 "$runtime" +stage=$(mktemp -d "$runtime/.render-stage.XXXXXX") +chmod 0700 "$stage" +public_tmp= +status_tmp= +config_tmp= +cleanup() { + [ -z "$public_tmp" ] || rm -f "$public_tmp" + [ -z "$status_tmp" ] || rm -f "$status_tmp" + [ -z "$config_tmp" ] || rm -f "$config_tmp" + rm -rf "$stage" +} +trap cleanup EXIT HUP INT TERM + +RIGOS_RUNTIME_PATH="$stage" "$renderer" + +"$jq_bin" -e ' + .schema == "rigos.runtime-config-status/v1" and + .outcome == "ready" and + (.revision | type == "string") and + (.algorithm | type == "string") +' "$stage/runtime-config-status.json" >/dev/null + +algorithm=$("$jq_bin" -r '.algorithm' "$stage/runtime-config-status.json") +revision=$("$jq_bin" -r '.revision' "$stage/runtime-config-status.json") +thread_count=$("$jq_bin" -r '.exact_threads // empty' "$stage/runtime-config-status.json") +profile=$("$jq_bin" -r '.profile // empty' "$stage/runtime-config-status.json") + +public_tmp=$(mktemp "$runtime/.xmrig-public.XXXXXX") +status_tmp=$(mktemp "$runtime/.runtime-status.XXXXXX") +config_tmp=$(mktemp "$runtime/.xmrig-runtime.XXXXXX") + +"$jq_bin" \ + --arg algorithm "$algorithm" \ + --arg revision "$revision" \ + --arg profile "$profile" \ + --argjson thread_count "${thread_count:-null}" ' + def present: with_entries(select(.value != null)); + def endpoint: + if type == "string" then split("@") | last else null end; + { + algo: $algorithm, + cpu: ({ + enabled: .cpu.enabled, + "huge-pages": .cpu["huge-pages"], + "max-threads-hint": .cpu["max-threads-hint"] + } | present), + pools: [ + .pools[] | ({ + url: (.url | endpoint), + algo: .algo, + tls: .tls, + nicehash: .nicehash, + keepalive: .keepalive, + priority: .priority + } | present) + ], + http: ({ + enabled: (.http.enabled // false), + host: .http.host, + port: .http.port, + restricted: .http.restricted + } | present), + randomx: { + "huge-pages": (.cpu["huge-pages"] // false) + }, + "rigos-public-view": { + schema: "rigos.xmrig-public-config/v1", + identity_redacted: true, + construction: "allowlist", + source_revision: $revision + } + } + | if $thread_count != null then .threads = $thread_count else . end + | if ($profile | length) > 0 and $thread_count != null + then .cpu[$profile] = [range(0; $thread_count) | -1] + else . + end +' "$stage/xmrig.json" >"$public_tmp" + +"$jq_bin" '. + { + identity_redacted_public_view: true, + public_view_construction: "allowlist" +}' "$stage/runtime-config-status.json" >"$status_tmp" + +cp "$stage/xmrig.json" "$config_tmp" +chmod 0640 "$config_tmp" +if [ "${RIGOS_RENDER_SKIP_CHOWN:-0}" != 1 ]; then + chown root:rigos "$config_tmp" +fi +chmod 0644 "$public_tmp" "$status_tmp" +sync -f "$config_tmp" +sync -f "$public_tmp" +sync -f "$status_tmp" +mv -f "$config_tmp" "$runtime/xmrig.json" +config_tmp= +mv -f "$public_tmp" "$runtime/xmrig-public.json" +public_tmp= +mv -f "$status_tmp" "$runtime/runtime-config-status.json" +status_tmp= +sync -f "$runtime" 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 9e5a0fc2..3effedd9 100644 --- a/build/usb/includes.chroot/usr/lib/rigos/rigos-runtime-render +++ b/build/usb/includes.chroot/usr/lib/rigos/rigos-runtime-render @@ -102,6 +102,7 @@ def render() -> tuple[dict, dict, dict]: exact_threads = threads elif threads != "auto": raise RuntimeError("threads must be auto or an integer") + public = copy.deepcopy(runtime) for pool in public.get("pools", []): if isinstance(pool, dict): @@ -110,11 +111,25 @@ def render() -> tuple[dict, dict, dict]: http = public.get("http") if isinstance(http, dict): http.pop("access-token", None) + + public["algo"] = algorithm + if exact_threads is not None: + public["threads"] = exact_threads + else: + public.pop("threads", None) + public_randomx = public.get("randomx") + if not isinstance(public_randomx, dict): + public_randomx = {} + huge_pages = cpu.get("huge-pages") + if isinstance(huge_pages, bool): + public_randomx["huge-pages"] = huge_pages + public["randomx"] = public_randomx public["rigos-public-view"] = { "schema": "rigos.xmrig-public-config/v1", "identity_redacted": True, "source_revision": current.name, } + status = { "schema": "rigos.runtime-config-status/v1", "outcome": "ready", diff --git a/build/usb/includes.chroot/usr/local/bin/rigosctl b/build/usb/includes.chroot/usr/local/bin/rigosctl new file mode 100644 index 00000000..593f87d5 --- /dev/null +++ b/build/usb/includes.chroot/usr/local/bin/rigosctl @@ -0,0 +1,14 @@ +#!/bin/sh +set -eu + +for argument in "$@"; do + case "$argument" in + --xmrig-config|--xmrig-config=*) + exec /usr/lib/rigos/rigosctl "$@" + ;; + esac +done + +exec /usr/lib/rigos/rigosctl \ + --xmrig-config /run/rigos/xmrig-public.json \ + "$@" diff --git a/build/usb/includes.chroot/usr/local/bin/rigosd b/build/usb/includes.chroot/usr/local/bin/rigosd new file mode 100644 index 00000000..336a08dc --- /dev/null +++ b/build/usb/includes.chroot/usr/local/bin/rigosd @@ -0,0 +1,14 @@ +#!/bin/sh +set -eu + +for argument in "$@"; do + case "$argument" in + --xmrig-config|--xmrig-config=*) + exec /usr/lib/rigos/rigosd "$@" + ;; + esac +done + +exec /usr/lib/rigos/rigosd \ + --xmrig-config /run/rigos/xmrig-public.json \ + "$@" diff --git a/build/usb/package-lists/rigos.list.chroot b/build/usb/package-lists/rigos.list.chroot index 85b9c79b..fc7df3aa 100644 --- a/build/usb/package-lists/rigos.list.chroot +++ b/build/usb/package-lists/rigos.list.chroot @@ -1,8 +1,10 @@ +# Runtime dependencies required by boot-time RIGOS authorities. ca-certificates curl e2fsprogs fdisk gdisk +jq live-boot live-config linux-image-amd64 diff --git a/crates/rigos-config/tests/hive_exit_image_verifier.rs b/crates/rigos-config/tests/hive_exit_image_verifier.rs new file mode 100644 index 00000000..309650ba --- /dev/null +++ b/crates/rigos-config/tests/hive_exit_image_verifier.rs @@ -0,0 +1,52 @@ +use std::fs; +use std::path::PathBuf; + +fn repo_path(path: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .join(path) +} + +#[test] +fn image_verifier_requires_runtime_authority_and_stability_bytes() { + let verifier = fs::read_to_string(repo_path("scripts/verify-usb-appliance.sh")).unwrap(); + let package_path = repo_path("build/usb/package-lists/rigos.list.chroot"); + let package_bytes = fs::read(&package_path).unwrap(); + assert!( + !package_bytes.contains(&b'\r'), + "appliance package list contains CR/CRLF line endings" + ); + let packages = String::from_utf8(package_bytes).unwrap(); + + assert!( + packages.lines().any(|line| line.trim() == "jq"), + "appliance package list is missing jq" + ); + + for required in [ + "usr/bin/jq", + "jq runtime dependency is missing from the appliance", + "jq_bin=${RIGOS_JQ:-/usr/bin/jq}", + "usr/lib/rigos/rigos-runtime-publish", + "usr/lib/rigos/rigos-runtime-authority", + "usr/lib/rigos/rigos-miner-health", + "usr/local/bin/rigosd", + "usr/local/bin/rigosctl", + "etc/systemd/system/rigos-runtime-render.service", + "etc/systemd/system/rigos-miner.service.d/runtime-render.conf", + "etc/systemd/system/rigos-miner.service.d/stability.conf", + "etc/systemd/system/rigos-miner-health.timer", + "ExecStart=/usr/lib/rigos/rigos-runtime-authority", + "ExecCondition=+/usr/lib/rigos/rigos-runtime-authority", + "ExecStart=/usr/lib/rigos/xmrig -c /run/rigos/xmrig.json", + "--xmrig-config /run/rigos/xmrig-public.json", + "flock -x -w 30", + "construction: \"allowlist\"", + "StartLimitBurst=5", + ] { + assert!( + verifier.contains(required), + "image verifier is missing contract: {required}" + ); + } +} diff --git a/crates/rigos-config/tests/hive_exit_runtime_publish.rs b/crates/rigos-config/tests/hive_exit_runtime_publish.rs new file mode 100644 index 00000000..55462e35 --- /dev/null +++ b/crates/rigos-config/tests/hive_exit_runtime_publish.rs @@ -0,0 +1,247 @@ +use serde_json::Value; +use std::fs; +use std::os::unix::fs::{PermissionsExt, symlink}; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::{SystemTime, UNIX_EPOCH}; + +fn repo_path(path: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .join(path) +} + +fn write_json(path: &Path, value: Value) { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write(path, serde_json::to_vec(&value).unwrap()).unwrap(); +} + +fn read_json(path: &Path) -> Value { + serde_json::from_slice(&fs::read(path).unwrap()).unwrap() +} + +#[test] +fn staged_runtime_publication_is_allowlisted_atomic_and_fail_closed() { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let root = std::env::temp_dir().join(format!("rigos-runtime-publish-{unique}")); + let state = root.join("state"); + let runtime = root.join("run"); + let revision = state.join("revisions/r1"); + fs::create_dir_all(revision.join("flight-sheets")).unwrap(); + fs::create_dir_all(&runtime).unwrap(); + symlink("revisions/r1", state.join("current")).unwrap(); + + write_json( + &revision.join("policy.json"), + serde_json::json!({ + "schema": "rigos.policy/v1", + "active_flight_sheet": "xmr" + }), + ); + write_json( + &revision.join("flight-sheets/xmr.json"), + serde_json::json!({ + "schema": "rigos.flight-sheet/v1", + "backend": "xmrig", + "algorithm": "rx/0", + "cpu": { + "threads": 2, + "huge_pages": true, + "max_threads_hint": 100 + } + }), + ); + write_json( + &revision.join("xmrig.json"), + serde_json::json!({ + "future_top": "TOP_SENTINEL", + "cpu": { + "enabled": true, + "huge-pages": true, + "max-threads-hint": 2, + "future_cpu": "CPU_SENTINEL" + }, + "pools": [{ + "url": "identity:worker@pool.test:1", + "algo": "rx/0", + "user": "IDENTITY_SENTINEL", + "pass": "WORKER_SENTINEL", + "future_pool": "POOL_SENTINEL" + }], + "http": { + "enabled": false, + "access-token": "TOKEN_SENTINEL", + "future_http": "HTTP_SENTINEL" + } + }), + ); + + let renderer = repo_path("build/usb/includes.chroot/usr/lib/rigos/rigos-runtime-render"); + let publisher = repo_path("build/usb/includes.chroot/usr/lib/rigos/rigos-runtime-publish"); + let gate = repo_path("build/usb/includes.chroot/usr/lib/rigos/rigos-runtime-gate"); + let renderer_wrapper = root.join("renderer"); + fs::write( + &renderer_wrapper, + format!("#!/bin/sh\nexec python3 '{}'\n", renderer.display()), + ) + .unwrap(); + fs::set_permissions(&renderer_wrapper, fs::Permissions::from_mode(0o755)).unwrap(); + + let jq = Command::new("jq").arg("--version").status().unwrap(); + assert!( + jq.success(), + "jq is required by runtime publication authority" + ); + + let result = Command::new("/bin/sh") + .arg(&publisher) + .env("RIGOS_STATE_PATH", &state) + .env("RIGOS_RUNTIME_PATH", &runtime) + .env("RIGOS_RUNTIME_RENDERER", &renderer_wrapper) + .env("RIGOS_RENDER_SKIP_CHOWN", "1") + .status() + .unwrap(); + assert!(result.success(), "runtime publisher failed"); + + let private = read_json(&runtime.join("xmrig.json")); + 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 public = read_json(&runtime.join("xmrig-public.json")); + let public_keys = public + .as_object() + .unwrap() + .keys() + .cloned() + .collect::>(); + assert_eq!( + public_keys, + vec![ + "algo", + "cpu", + "http", + "pools", + "randomx", + "rigos-public-view", + "threads" + ] + ); + assert_eq!(public["algo"], "rx/0"); + assert_eq!(public["threads"], 2); + assert_eq!(public["cpu"]["max-threads-hint"], 100); + assert_eq!(public["cpu"]["rx"], serde_json::json!([-1, -1])); + 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"); + let public_text = serde_json::to_string(&public).unwrap(); + for sentinel in [ + "TOP_SENTINEL", + "CPU_SENTINEL", + "POOL_SENTINEL", + "HTTP_SENTINEL", + "IDENTITY_SENTINEL", + "WORKER_SENTINEL", + "TOKEN_SENTINEL", + ] { + assert!( + !public_text.contains(sentinel), + "public view leaked {sentinel}" + ); + } + + let status = read_json(&runtime.join("runtime-config-status.json")); + assert_eq!(status["outcome"], "ready"); + assert_eq!(status["revision"], "r1"); + assert_eq!(status["exact_threads"], 2); + assert_eq!(status["public_view_construction"], "allowlist"); + + assert_eq!( + fs::metadata(runtime.join("xmrig.json")) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o640 + ); + for path in [ + runtime.join("xmrig-public.json"), + runtime.join("runtime-config-status.json"), + ] { + assert_eq!( + fs::metadata(path).unwrap().permissions().mode() & 0o777, + 0o644 + ); + } + let leftovers = fs::read_dir(&runtime) + .unwrap() + .filter_map(Result::ok) + .map(|entry| entry.file_name().to_string_lossy().into_owned()) + .filter(|name| name.starts_with('.')) + .collect::>(); + assert!( + leftovers.is_empty(), + "temporary runtime files remain: {leftovers:?}" + ); + + let allowed = Command::new("python3") + .arg(&gate) + .arg("--state") + .arg(&state) + .arg("--runtime") + .arg(&runtime) + .status() + .unwrap(); + assert!(allowed.success()); + + let mut invalid = private; + invalid["cpu"]["rx"] = serde_json::json!([-1]); + write_json(&runtime.join("xmrig.json"), invalid); + let denied = Command::new("python3") + .arg(&gate) + .arg("--state") + .arg(&state) + .arg("--runtime") + .arg(&runtime) + .status() + .unwrap(); + assert_eq!(denied.code(), Some(2)); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn missing_jq_is_rejected_before_runtime_staging() { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let root = std::env::temp_dir().join(format!("rigos-runtime-missing-jq-{unique}")); + let runtime = root.join("run"); + fs::create_dir_all(&runtime).unwrap(); + + let publisher = repo_path("build/usb/includes.chroot/usr/lib/rigos/rigos-runtime-publish"); + let missing_jq = root.join("missing-jq"); + let output = Command::new("/bin/sh") + .arg(&publisher) + .env("RIGOS_RUNTIME_PATH", &runtime) + .env("RIGOS_JQ", &missing_jq) + .output() + .unwrap(); + + assert_eq!(output.status.code(), Some(127)); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!(stderr.contains("required jq runtime is missing")); + assert!(stderr.contains(missing_jq.to_str().unwrap())); + assert_eq!(fs::read_dir(&runtime).unwrap().count(), 0); + + let _ = fs::remove_dir_all(root); +} diff --git a/crates/rigos-config/tests/hive_exit_shell_syntax.rs b/crates/rigos-config/tests/hive_exit_shell_syntax.rs new file mode 100644 index 00000000..f8087c5e --- /dev/null +++ b/crates/rigos-config/tests/hive_exit_shell_syntax.rs @@ -0,0 +1,30 @@ +use std::fs; +use std::path::PathBuf; +use std::process::Command; + +fn repo_path(path: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .join(path) +} + +#[test] +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", + ] { + let full_path = repo_path(path); + let bytes = fs::read(&full_path).unwrap(); + assert!( + !bytes.contains(&b'\r'), + "shell authority contains CR/CRLF line endings: {path}" + ); + let status = Command::new("/bin/sh") + .arg("-n") + .arg(&full_path) + .status() + .unwrap(); + assert!(status.success(), "shell syntax failed: {path}"); + } +} diff --git a/crates/rigos-config/tests/miner_stability.rs b/crates/rigos-config/tests/miner_stability.rs new file mode 100644 index 00000000..37a6405c --- /dev/null +++ b/crates/rigos-config/tests/miner_stability.rs @@ -0,0 +1,199 @@ +use serde_json::Value; +use std::fs; +use std::os::unix::fs::{PermissionsExt, symlink}; +use std::path::{Path, PathBuf}; +use std::process::Command; +use uuid::Uuid; + +fn repo_path(path: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .join(path) +} + +fn write_executable(path: &Path, content: &str) { + fs::write(path, content).unwrap(); + let mut permissions = fs::metadata(path).unwrap().permissions(); + permissions.set_mode(0o755); + fs::set_permissions(path, permissions).unwrap(); +} + +fn write_runtime_status(path: &Path, revision: &str) { + fs::write( + path, + serde_json::to_vec(&serde_json::json!({ + "schema": "rigos.runtime-config-status/v1", + "outcome": "ready", + "revision": revision + })) + .unwrap(), + ) + .unwrap(); +} + +fn run_observer( + root: &Path, + systemctl: &Path, + journalctl: &Path, + systemctl_fixture: &Path, + journal_fixture: &Path, +) -> Value { + let status = Command::new("python3") + .arg(repo_path( + "build/usb/includes.chroot/usr/lib/rigos/rigos-miner-health", + )) + .env("RIGOS_RUNTIME_PATH", root.join("run")) + .env("RIGOS_BOOT_ID_PATH", root.join("boot-id")) + .env("RIGOS_CURRENT_REVISION_PATH", root.join("state/current")) + .env("RIGOS_PROC_ROOT", root.join("proc")) + .env("RIGOS_SYSTEMCTL", systemctl) + .env("RIGOS_JOURNALCTL", journalctl) + .env("RIGOS_SYSTEMCTL_FIXTURE", systemctl_fixture) + .env("RIGOS_JOURNAL_FIXTURE", journal_fixture) + .status() + .unwrap(); + assert!(status.success()); + serde_json::from_slice(&fs::read(root.join("run/miner-health-status.json")).unwrap()).unwrap() +} + +#[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())); + fs::create_dir_all(root.join("run")).unwrap(); + fs::create_dir_all(root.join("state/revisions/r1")).unwrap(); + fs::create_dir_all(root.join("proc/123")).unwrap(); + symlink("revisions/r1", root.join("state/current")).unwrap(); + 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(); + write_runtime_status(&root.join("run/runtime-config-status.json"), "r1"); + + let systemctl_fixture = root.join("systemctl.txt"); + fs::write( + &systemctl_fixture, + concat!( + "ActiveState=active\n", + "SubState=running\n", + "MainPID=123\n", + "NRestarts=2\n", + "Result=success\n", + "ExecMainStatus=0\n", + "ActiveEnterTimestampMonotonic=100000000\n" + ), + ) + .unwrap(); + let journal_fixture = root.join("journal.txt"); + let systemctl = root.join("systemctl"); + let journalctl = root.join("journalctl"); + let journalctl_fail = root.join("journalctl-fail"); + write_executable(&systemctl, "#!/bin/sh\ncat \"$RIGOS_SYSTEMCTL_FIXTURE\"\n"); + write_executable(&journalctl, "#!/bin/sh\ncat \"$RIGOS_JOURNAL_FIXTURE\"\n"); + write_executable(&journalctl_fail, "#!/bin/sh\nexit 1\n"); + + 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" + ), + ) + .unwrap(); + let ready = run_observer( + &root, + &systemctl, + &journalctl, + &systemctl_fixture, + &journal_fixture, + ); + assert_eq!(ready["state"], "ready"); + assert_eq!(ready["unit"]["restart_count"], 2); + 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(); + let waiting = run_observer( + &root, + &systemctl, + &journalctl, + &systemctl_fixture, + &journal_fixture, + ); + assert_eq!(waiting["state"], "waiting_external"); + assert_eq!(waiting["reason"], "pool_or_network_unavailable"); + + fs::write(&journal_fixture, "").unwrap(); + let degraded = run_observer( + &root, + &systemctl, + &journalctl, + &systemctl_fixture, + &journal_fixture, + ); + assert_eq!(degraded["state"], "degraded"); + assert_eq!(degraded["reason"], "no_recent_speed_evidence"); + + let unknown = run_observer( + &root, + &systemctl, + &journalctl_fail, + &systemctl_fixture, + &journal_fixture, + ); + assert_eq!(unknown["state"], "unknown"); + assert_eq!(unknown["reason"], "journal_unavailable"); + assert_eq!(unknown["evidence"]["journal_available"], false); + + write_runtime_status(&root.join("run/runtime-config-status.json"), "r2"); + let blocked = run_observer( + &root, + &systemctl, + &journalctl, + &systemctl_fixture, + &journal_fixture, + ); + assert_eq!(blocked["state"], "blocked"); + assert_eq!(blocked["reason"], "runtime_revision_mismatch"); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn miner_restart_policy_is_bounded_and_observer_never_mutates_service() { + let stability = fs::read_to_string(repo_path( + "build/usb/includes.chroot/etc/systemd/system/rigos-miner.service.d/stability.conf", + )) + .unwrap(); + assert!(stability.contains("StartLimitIntervalSec=10min")); + assert!(stability.contains("StartLimitBurst=5")); + assert!(stability.contains("Restart=on-failure")); + assert!(stability.contains("RestartSec=15s")); + assert!(stability.contains("TimeoutStopSec=30s")); + assert!(!stability.contains("Restart=always")); + + let observer = fs::read_to_string(repo_path( + "build/usb/includes.chroot/usr/lib/rigos/rigos-miner-health", + )) + .unwrap(); + assert!(observer.contains("\"remediation\": \"observe_only\"")); + assert!(observer.contains("MAX_JOURNAL_LINES = 500")); + assert!(!observer.contains("systemctl restart")); + assert!(!observer.contains("systemctl kill")); + + let service = fs::read_to_string(repo_path( + "build/usb/includes.chroot/etc/systemd/system/rigos-miner-health.service", + )) + .unwrap(); + assert!(!service.contains("Wants=rigos-miner.service")); + assert!(!service.contains("Requires=rigos-miner.service")); + + let timer = fs::read_to_string(repo_path( + "build/usb/includes.chroot/etc/systemd/system/rigos-miner-health.timer", + )) + .unwrap(); + assert!(timer.contains("OnBootSec=2min")); + assert!(timer.contains("OnUnitActiveSec=1min")); + + let hook = fs::read_to_string(repo_path("build/usb/hooks/010-rigos.chroot")).unwrap(); + assert!(hook.contains("rigos-miner-health.timer")); +} diff --git a/crates/rigos-config/tests/recovery_access.rs b/crates/rigos-config/tests/recovery_access.rs index 4d6b5772..79e156b2 100644 --- a/crates/rigos-config/tests/recovery_access.rs +++ b/crates/rigos-config/tests/recovery_access.rs @@ -143,20 +143,46 @@ fn alpha8_runtime_authority_is_exact_and_fail_closed() { fn alpha8_appliance_wiring_is_explicit() { let hook = fs::read_to_string(repo_path("build/usb/hooks/010-rigos.chroot")) .expect("read appliance hook"); - assert!(hook.contains("ln -sfn /usr/lib/rigos/rigosd /usr/local/bin/rigosd")); - assert!(hook.contains("ln -sfn /usr/lib/rigos/rigosctl /usr/local/bin/rigosctl")); + assert!(hook.contains("chmod 0755 /usr/local/bin/rigosd /usr/local/bin/rigosctl")); + assert!(!hook.contains("ln -sfn /usr/lib/rigos/rigosd /usr/local/bin/rigosd")); + assert!(!hook.contains("ln -sfn /usr/lib/rigos/rigosctl /usr/local/bin/rigosctl")); + assert!(hook.contains("/usr/lib/rigos/rigos-runtime-publish")); assert!(hook.contains("rigos-runtime-render.service")); assert!(hook.contains("systemctl disable ssh.socket")); + for command in ["rigosd", "rigosctl"] { + let wrapper = fs::read_to_string(repo_path(&format!( + "build/usb/includes.chroot/usr/local/bin/{command}" + ))) + .expect("read inspector wrapper"); + assert!(wrapper.contains("--xmrig-config /run/rigos/xmrig-public.json")); + assert!(wrapper.contains(&format!("exec /usr/lib/rigos/{command}"))); + } + + let runtime_service = fs::read_to_string(repo_path( + "build/usb/includes.chroot/etc/systemd/system/rigos-runtime-render.service", + )) + .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; + assert_ne!(mode.mode() & 0o111, 0); + } + let miner = fs::read_to_string(repo_path( "build/usb/includes.chroot/etc/systemd/system/rigos-miner.service.d/runtime-render.conf", )) .expect("read miner runtime override"); assert!(miner.contains("Requires=rigos-runtime-render.service")); assert!(miner.contains("ConditionPathExists=/var/lib/rigos/current")); - assert!(miner.contains("ExecCondition=+/usr/lib/rigos/rigos-runtime-render")); + assert!(miner.contains("ExecCondition=+/usr/lib/rigos/rigos-runtime-authority")); assert!(miner.contains("ExecCondition=/usr/lib/rigos/rigos-runtime-gate")); - assert!(miner.contains("--config=/run/rigos/xmrig.json")); + assert!(miner.contains("ExecStart=/usr/lib/rigos/xmrig -c /run/rigos/xmrig.json")); + assert!(!miner.contains("--config=/run/rigos/xmrig.json")); let ssh = fs::read_to_string(repo_path( "build/usb/includes.chroot/etc/systemd/system/ssh.service.d/rigos-observe.conf", diff --git a/crates/rigos-xmrig/tests/explicit_config_boundary.rs b/crates/rigos-xmrig/tests/explicit_config_boundary.rs new file mode 100644 index 00000000..ae7a854d --- /dev/null +++ b/crates/rigos-xmrig/tests/explicit_config_boundary.rs @@ -0,0 +1,82 @@ +use rigos_machine::MachineContext; +use rigos_miner::MinerBackend; +use rigos_xmrig::{ConfigParseState, XmrigBackend}; +use std::fs; +use std::time::{SystemTime, UNIX_EPOCH}; + +#[test] +fn explicit_public_config_wins_when_runtime_uses_xmrig_short_config_option() { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let root = std::env::temp_dir().join(format!("rigos-explicit-config-{unique}")); + let proc_root = root.join("proc"); + let pid_dir = proc_root.join("42"); + fs::create_dir_all(&pid_dir).unwrap(); + + let private = root.join("private.json"); + let public = root.join("public.json"); + fs::write( + &private, + r#"{"algo":"rx/private","threads":99,"future":"PRIVATE_SENTINEL","http":{"enabled":false}}"#, + ) + .unwrap(); + fs::write( + &public, + r#"{"algo":"rx/0","threads":2,"randomx":{"huge-pages":true},"pools":[{"url":"pool.test:1"}],"http":{"enabled":false}}"#, + ) + .unwrap(); + + fs::write(pid_dir.join("comm"), "xmrig\n").unwrap(); + let mut cmdline = Vec::new(); + cmdline.extend_from_slice(b"xmrig\0-c\0"); + cmdline.extend_from_slice(private.as_os_str().as_encoded_bytes()); + cmdline.push(0); + fs::write(pid_dir.join("cmdline"), cmdline).unwrap(); + fs::write( + pid_dir.join("status"), + "Name:\txmrig\nUid:\t1000 1000 1000 1000\n", + ) + .unwrap(); + fs::write( + pid_dir.join("cgroup"), + "0::/system.slice/rigos-miner.service\n", + ) + .unwrap(); + fs::write( + pid_dir.join("stat"), + "42 (xmrig) S 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 100 0\n", + ) + .unwrap(); + fs::write(proc_root.join("uptime"), "100.0 0.0\n").unwrap(); + + let expected_path = public.to_string_lossy().into_owned(); + let backend = XmrigBackend { + explicit_executable: None, + explicit_config: Some(public.clone()), + probe_version: false, + }; + let result = backend.discover(&MachineContext { + proc_root, + sys_root: root.join("sys"), + }); + let _ = fs::remove_dir_all(root); + + let snapshot = result.value.unwrap(); + assert!(snapshot.running); + assert_eq!(snapshot.config.path, Some(expected_path)); + assert!(matches!( + snapshot.config.parse_state, + ConfigParseState::Valid + )); + assert_eq!(snapshot.config.algorithm.as_deref(), Some("rx/0")); + assert_eq!(snapshot.config.thread_hint, Some(2)); + assert_eq!(snapshot.config.huge_pages_requested, Some(true)); + assert_eq!(snapshot.config.pools, vec!["pool.test:1"]); + assert!( + !serde_json::to_string(&snapshot) + .unwrap() + .contains("PRIVATE_SENTINEL") + ); +} diff --git a/docs/hive-exit-hostile-review.md b/docs/hive-exit-hostile-review.md new file mode 100644 index 00000000..d4904ab0 --- /dev/null +++ b/docs/hive-exit-hostile-review.md @@ -0,0 +1,51 @@ +HIVE EXIT HOSTILE REVIEW +======================== + +The first stability batch passed source verification, but review found two +release blockers before image construction. + +1. The inspector must never select the identity-bearing runtime configuration + when the public configuration was explicitly requested. + +2. The public XMRig view must be constructed from an allowlist. Copying the + private configuration and deleting known fields is not a security boundary. + +CURRENT BOUNDARY +---------------- + +The legacy renderer now runs only inside a private staging directory under +/run/rigos with mode 0700. The runtime publication authority then creates a +new public document from an explicit jq allowlist and atomically publishes: + + /run/rigos/xmrig.json mode 0640 + /run/rigos/xmrig-public.json mode 0644 + /run/rigos/runtime-config-status.json mode 0644 + +The status document is published last. A failed publication therefore cannot +claim a new ready state before the private and public documents are in place. + +The managed XMRig process uses the supported short option: + + xmrig -c /run/rigos/xmrig.json + +RIGOS process discovery currently recognizes only the long --config forms. +The rigosd and rigosctl wrappers explicitly select xmrig-public.json, so the +managed process command line cannot override the public inspection path. +This compatibility boundary is covered by an integration test and must not be +changed independently from the parser or systemd wiring. + +REQUIRED VERIFICATION +--------------------- + + cargo fmt --all -- --check + cargo test -p rigos-xmrig --test explicit_config_boundary + cargo test -p rigos-config --test hive_exit_runtime_publish + ./scripts/verify.sh + +The publication fixture injects unknown sentinel fields at the top level and +inside CPU, pool, and HTTP objects. Private runtime truth must retain them. +The public view must contain none of them. + +GitHub Actions are disabled for this account. Verification evidence therefore +comes from the local WSL source gate and exact artifact provenance, not from a +remote workflow badge. diff --git a/scripts/check-alpha8-runtime.py b/scripts/check-alpha8-runtime.py index cd67fff7..21846204 100644 --- a/scripts/check-alpha8-runtime.py +++ b/scripts/check-alpha8-runtime.py @@ -48,8 +48,18 @@ def verify_runtime_authority(root: Path) -> None: "huge-pages": True, "max-threads-hint": 2, }, - "pools": [{"url": "pool.test:1", "algo": "rx/0"}], - "http": {"enabled": False}, + "pools": [ + { + "url": "pool.test:1", + "algo": "rx/0", + "user": "fixture-private-identity", + "pass": "fixture-worker", + } + ], + "http": { + "enabled": False, + "access-token": "fixture-private-token", + }, }, ) environment = os.environ.copy() @@ -64,6 +74,21 @@ def verify_runtime_authority(root: Path) -> None: config = json.loads((runtime / "xmrig.json").read_text(encoding="utf-8")) assert config["cpu"]["max-threads-hint"] == 100 assert config["cpu"]["rx"] == [-1, -1] + assert config["pools"][0]["user"] == "fixture-private-identity" + + public = json.loads((runtime / "xmrig-public.json").read_text(encoding="utf-8")) + assert public["algo"] == "rx/0" + assert public["threads"] == 2 + assert public["randomx"]["huge-pages"] is True + assert public["rigos-public-view"]["identity_redacted"] is True + assert "user" not in public["pools"][0] + assert "pass" not in public["pools"][0] + assert "access-token" not in public["http"] + public_text = json.dumps(public, sort_keys=True) + assert "fixture-private-identity" not in public_text + assert "fixture-worker" not in public_text + assert "fixture-private-token" not in public_text + status = json.loads( (runtime / "runtime-config-status.json").read_text(encoding="utf-8") ) @@ -142,7 +167,7 @@ def main() -> int: root = Path(temporary) verify_runtime_authority(root) verify_remote_truth(root) - print("RIGOS Alpha8 runtime and remote truth verification passed") + print("RIGOS runtime, redacted inspector, and remote truth verification passed") return 0 diff --git a/scripts/verify-usb-appliance.sh b/scripts/verify-usb-appliance.sh index 812f8ed5..6fab56fe 100755 --- a/scripts/verify-usb-appliance.sh +++ b/scripts/verify-usb-appliance.sh @@ -82,17 +82,28 @@ unsquashfs -no-progress -d "$temporary/root" "$temporary/a/live/filesystem.squas etc/systemd/system/rigos-firstboot.service \ etc/systemd/system/rigos-hugepages.service \ etc/systemd/system/rigos-miner.service \ + etc/systemd/system/rigos-miner.service.d/runtime-render.conf \ + etc/systemd/system/rigos-miner.service.d/stability.conf \ + etc/systemd/system/rigos-miner-health.service \ + etc/systemd/system/rigos-miner-health.timer \ + etc/systemd/system/rigos-runtime-render.service \ etc/systemd/system/rigos-profile-apply.service \ - usr/bin/python3 usr/bin/python3.11 \ + usr/bin/jq usr/bin/python3 usr/bin/python3.11 \ 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/xmrig usr/local/sbin/rigosctl usr/local/sbin/rigos-firstboot usr/local/sbin/rigos-recovery-access usr/local/sbin/rigos-state-orchestrate \ + 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/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' 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" +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" rigosctl_path="$(PATH="$temporary/root/usr/local/sbin:$temporary/root/usr/bin" command -v rigosctl)" [[ "$rigosctl_path" == "$temporary/root/usr/local/sbin/rigosctl" && -x "$rigosctl_path" ]] || die 'rigosctl is not executable in the appliance PATH' @@ -120,6 +131,23 @@ strings "$temporary/root/usr/lib/rigos/rigos-performance" | grep -F 'rigos.perfo if strings "$temporary/root/usr/lib/rigos/rigos-performance" | grep -F 'sysctl' >/dev/null; then die 'performance authority shells out to sysctl'; fi if strings "$temporary/root/usr/lib/rigos/rigos-performance" | grep -Ei '(/dev/sd|/dev/nvme|cpu model)' >/dev/null; then die 'performance authority contains hardware-name or internal-disk targeting'; fi grep -Fq 'ExecCondition=/usr/lib/rigos/rigos-miner-gate' "$temporary/root/etc/systemd/system/rigos-miner.service" || die 'miner safety gate is missing' +[[ -x "$temporary/root/usr/lib/rigos/rigos-runtime-render" ]] || die 'legacy runtime renderer is missing' +[[ -x "$temporary/root/usr/lib/rigos/rigos-runtime-publish" ]] || die 'runtime allowlist publisher is missing' +[[ -x "$temporary/root/usr/lib/rigos/rigos-runtime-authority" ]] || die 'serialized runtime authority is missing' +grep -Fq 'ExecStart=/usr/lib/rigos/rigos-runtime-authority' "$temporary/root/etc/systemd/system/rigos-runtime-render.service" || die 'serialized runtime authority is not wired' +grep -Fq 'ExecCondition=+/usr/lib/rigos/rigos-runtime-authority' "$temporary/root/etc/systemd/system/rigos-miner.service.d/runtime-render.conf" || die 'miner does not serialize runtime publication before start' +grep -Fq 'ExecCondition=/usr/lib/rigos/rigos-runtime-gate' "$temporary/root/etc/systemd/system/rigos-miner.service.d/runtime-render.conf" || die 'runtime gate is missing from miner override' +grep -Fq 'ExecStart=/usr/lib/rigos/xmrig -c /run/rigos/xmrig.json' "$temporary/root/etc/systemd/system/rigos-miner.service.d/runtime-render.conf" || die 'managed miner does not use the short private runtime config option' +grep -Fq 'flock -x -w 30' "$temporary/root/usr/lib/rigos/rigos-runtime-authority" || die 'runtime publication lock is missing' +grep -Fq 'jq_bin=${RIGOS_JQ:-/usr/bin/jq}' "$temporary/root/usr/lib/rigos/rigos-runtime-publish" || die 'runtime publisher does not use the absolute jq dependency' +grep -Fq 'construction: "allowlist"' "$temporary/root/usr/lib/rigos/rigos-runtime-publish" || die 'public runtime allowlist marker is missing' +grep -Fq '.render-stage.XXXXXX' "$temporary/root/usr/lib/rigos/rigos-runtime-publish" || die 'private runtime staging directory is missing' +grep -Fq -- '--xmrig-config /run/rigos/xmrig-public.json' "$temporary/root/usr/local/bin/rigosd" || die 'rigosd does not default to the public runtime view' +grep -Fq -- '--xmrig-config /run/rigos/xmrig-public.json' "$temporary/root/usr/local/bin/rigosctl" || die 'rigosctl does not default to the public runtime view' +[[ -x "$temporary/root/usr/lib/rigos/rigos-miner-health" ]] || die 'miner health observer is missing' +grep -Fq 'OnUnitActiveSec=1min' "$temporary/root/etc/systemd/system/rigos-miner-health.timer" || die 'miner health timer cadence is missing' +grep -Fq 'Restart=on-failure' "$temporary/root/etc/systemd/system/rigos-miner.service.d/stability.conf" || die 'bounded miner restart policy is missing' +grep -Fq 'StartLimitBurst=5' "$temporary/root/etc/systemd/system/rigos-miner.service.d/stability.conf" || die 'miner crash-loop ceiling is missing' [[ -f "$temporary/root/etc/systemd/system/rigos-profile-apply.service" ]] || die 'profile apply service is missing' grep -Fq 'ExecCondition=/usr/lib/rigos/rigos-config needs-activation' "$temporary/root/etc/systemd/system/rigos-firstboot.service" || die 'first boot activation gate is missing' grep -Fq 'ExecStart=/usr/local/sbin/rigos-recovery-access' "$temporary/root/etc/systemd/system/rigos-recovery-access.service" || die 'local recovery access phase is missing'