diff --git a/build/usb/Dockerfile b/build/usb/Dockerfile index 9f773489..be63222a 100644 --- a/build/usb/Dockerfile +++ b/build/usb/Dockerfile @@ -7,5 +7,11 @@ RUN apt-get update \ ripgrep rsync squashfs-tools syslinux-common syslinux-utils udev uuid-runtime xorriso zstd \ && rm -rf /var/lib/apt/lists/* +ENV PATH="/usr/local/cargo/bin:/usr/local/rustup/bin:${PATH}" + +RUN command -v cargo \ + && cargo --version \ + && rustc --version + WORKDIR /source -ENTRYPOINT ["./scripts/build-usb-image.sh"] +ENTRYPOINT ["/bin/bash", "-c", "python3 ./scripts/check-alpha8-ssh-hotfix.py && exec ./scripts/build-usb-image.sh"] diff --git a/build/usb/hooks/010-rigos.chroot b/build/usb/hooks/010-rigos.chroot index e29c5d1d..f7e37ac5 100644 --- a/build/usb/hooks/010-rigos.chroot +++ b/build/usb/hooks/010-rigos.chroot @@ -5,11 +5,14 @@ useradd --system --home-dir /var/lib/rigos --shell /usr/sbin/nologin --user-grou 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 +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-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 rigos-state.service rigos-recovery-access.service rigos-state-ready.service rigos-profile-apply.service rigos-hugepages.service rigos-firstboot.service rigos-miner.service tmp.mount +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 +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/00-rigos.conf b/build/usb/includes.chroot/etc/ssh/sshd_config.d/00-rigos.conf new file mode 100644 index 00000000..720f859f --- /dev/null +++ b/build/usb/includes.chroot/etc/ssh/sshd_config.d/00-rigos.conf @@ -0,0 +1,12 @@ +PasswordAuthentication yes +KbdInteractiveAuthentication no +UsePAM yes +AuthenticationMethods any +PubkeyAuthentication yes +PermitRootLogin no +AllowUsers rigosadmin +X11Forwarding no +AllowAgentForwarding no +AllowTcpForwarding no +PermitTunnel no +GatewayPorts no 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 new file mode 100644 index 00000000..030eecfd --- /dev/null +++ b/build/usb/includes.chroot/etc/systemd/system/rigos-miner.service.d/runtime-render.conf @@ -0,0 +1,11 @@ +[Unit] +After=rigos-runtime-render.service +Requires=rigos-runtime-render.service +ConditionPathExists= +ConditionPathExists=/var/lib/rigos/current + +[Service] +ExecCondition=+/usr/lib/rigos/rigos-runtime-render +ExecCondition=/usr/lib/rigos/rigos-runtime-gate +ExecStart= +ExecStart=/usr/lib/rigos/xmrig --config=/run/rigos/xmrig.json diff --git a/build/usb/includes.chroot/etc/systemd/system/rigos-recovery-access.service b/build/usb/includes.chroot/etc/systemd/system/rigos-recovery-access.service index 228e2a24..ca00519e 100644 --- a/build/usb/includes.chroot/etc/systemd/system/rigos-recovery-access.service +++ b/build/usb/includes.chroot/etc/systemd/system/rigos-recovery-access.service @@ -2,11 +2,13 @@ Description=Establish local RIGOS recovery access After=rigos-state.service Wants=rigos-state.service -Before=rigos-state-ready.service rigos-firstboot.service getty@tty1.service +Before=rigos-state-ready.service rigos-firstboot.service getty@tty1.service ssh.service [Service] Type=oneshot ExecStart=/usr/local/sbin/rigos-recovery-access +SuccessExitStatus=1 +ExecStartPost=/usr/bin/python3 /usr/lib/rigos/rigos-recovery-access-verify StandardInput=tty StandardOutput=tty StandardError=tty diff --git a/build/usb/includes.chroot/etc/systemd/system/rigos-remote-access-observe.service b/build/usb/includes.chroot/etc/systemd/system/rigos-remote-access-observe.service new file mode 100644 index 00000000..8f0afe23 --- /dev/null +++ b/build/usb/includes.chroot/etc/systemd/system/rigos-remote-access-observe.service @@ -0,0 +1,10 @@ +[Unit] +Description=Observe RIGOS SSH listener truth +After=network-online.target ssh.service rigos-recovery-access.service +Wants=network-online.target +Requires=ssh.service rigos-recovery-access.service + +[Service] +Type=oneshot +ExecStart=/usr/bin/python3 /usr/lib/rigos/rigos-remote-access-probe +RemainAfterExit=yes 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 new file mode 100644 index 00000000..166dfc49 --- /dev/null +++ b/build/usb/includes.chroot/etc/systemd/system/rigos-runtime-render.service @@ -0,0 +1,24 @@ +[Unit] +Description=Render 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 +ConditionPathExists=/var/lib/rigos/current + +[Service] +Type=oneshot +ExecStart=/usr/lib/rigos/rigos-runtime-render +NoNewPrivileges=yes +PrivateTmp=yes +ProtectHome=yes +ProtectSystem=strict +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectControlGroups=yes +RestrictAddressFamilies=AF_UNIX +RestrictNamespaces=yes +LockPersonality=yes +ReadWritePaths=/run/rigos + +[Install] +WantedBy=multi-user.target diff --git a/build/usb/includes.chroot/etc/systemd/system/rigos-runtime-render.service.d/remain.conf b/build/usb/includes.chroot/etc/systemd/system/rigos-runtime-render.service.d/remain.conf new file mode 100644 index 00000000..75039e3a --- /dev/null +++ b/build/usb/includes.chroot/etc/systemd/system/rigos-runtime-render.service.d/remain.conf @@ -0,0 +1,2 @@ +[Service] +RemainAfterExit=yes 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 new file mode 100644 index 00000000..d00954c6 --- /dev/null +++ b/build/usb/includes.chroot/etc/systemd/system/ssh.service.d/rigos-observe.conf @@ -0,0 +1,3 @@ +[Unit] +After=rigos-recovery-access.service +Wants=rigos-remote-access-observe.service diff --git a/build/usb/includes.chroot/usr/lib/rigos/rigos-recovery-access-verify b/build/usb/includes.chroot/usr/lib/rigos/rigos-recovery-access-verify new file mode 100644 index 00000000..ac3dd072 --- /dev/null +++ b/build/usb/includes.chroot/usr/lib/rigos/rigos-recovery-access-verify @@ -0,0 +1,55 @@ +#!/usr/bin/python3 +import json +import os +import sys +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")) +STATUS = RUNTIME / "recovery-access-status.json" +MAX_STATUS_BYTES = 64 * 1024 + + +def emit(outcome: str, reason: str | None = None) -> None: + value = { + "schema": "rigos.recovery-access-gate/v1", + "outcome": outcome, + "reason": reason, + } + print(json.dumps(value, sort_keys=True), file=sys.stdout if outcome == "allowed" else sys.stderr) + + +def deny(reason: str) -> int: + emit("denied", reason) + return 2 + + +def main() -> int: + try: + boot_id = BOOT_ID.read_text(encoding="ascii").strip() + raw = STATUS.read_bytes() + if not boot_id or not raw or len(raw) > MAX_STATUS_BYTES: + return deny("recovery_status_unreadable") + status = json.loads(raw) + except (OSError, UnicodeError, json.JSONDecodeError): + return deny("recovery_status_unreadable") + + if not isinstance(status, dict): + return deny("recovery_status_invalid") + if status.get("schema") != "rigos.recovery-access-status/v1": + return deny("recovery_schema_mismatch") + if status.get("boot_id") != boot_id: + return deny("recovery_status_stale") + if status.get("local_console_access") is not True: + return deny("local_credential_unavailable") + if status.get("credential_persisted") is not True: + return deny("credential_not_persisted") + if status.get("credential_action") not in ("existing", "created", "restored"): + return deny("credential_action_invalid") + + emit("allowed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/build/usb/includes.chroot/usr/lib/rigos/rigos-remote-access-probe b/build/usb/includes.chroot/usr/lib/rigos/rigos-remote-access-probe new file mode 100644 index 00000000..b7b2d004 --- /dev/null +++ b/build/usb/includes.chroot/usr/lib/rigos/rigos-remote-access-probe @@ -0,0 +1,99 @@ +#!/usr/bin/python3 +import json +import os +import subprocess +import tempfile +from pathlib import Path + +RUNTIME = Path("/run/rigos") +STATUS = RUNTIME / "recovery-access-status.json" +BOOT_ID = Path("/proc/sys/kernel/random/boot_id") +SSH_PORT = 22 + + +def unit_state(action: str, unit: str) -> bool: + return subprocess.run( + ["/usr/bin/systemctl", action, "--quiet", unit], + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ).returncode == 0 + + +def has_listener(path: Path, port: int) -> bool: + try: + lines = path.read_text(encoding="ascii").splitlines()[1:] + except OSError: + return False + for line in lines: + fields = line.split() + if len(fields) < 4 or fields[3] != "0A": + continue + try: + observed = int(fields[1].rsplit(":", 1)[1], 16) + except (IndexError, ValueError): + continue + if observed == port: + return True + return False + + +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 main() -> int: + boot_id = BOOT_ID.read_text(encoding="ascii").strip() + status = json.loads(STATUS.read_text(encoding="utf-8")) + if status.get("boot_id") != boot_id: + raise RuntimeError("recovery access status belongs to another boot") + enabled = unit_state("is-enabled", "ssh.service") + active = unit_state("is-active", "ssh.service") + ipv4 = has_listener(Path("/proc/net/tcp"), SSH_PORT) + ipv6 = has_listener(Path("/proc/net/tcp6"), SSH_PORT) + listening = ipv4 or ipv6 + if enabled and active and listening: + remote_access = "active" + elif enabled and not listening: + remote_access = "enabled_no_listener" + elif listening and not active: + remote_access = "listener_without_service" + else: + remote_access = "inactive" + operational = ( + status.get("state_outcome") == "ready" + and status.get("local_console_access") is True + and remote_access == "active" + ) + status.update( + { + "mode": "operational" if operational else "recovery", + "remote_access": remote_access, + "remote_protocol": "ssh", + "remote_port": SSH_PORT, + "ssh_service_enabled": enabled, + "ssh_service_active": active, + "ssh_listener_ipv4": ipv4, + "ssh_listener_ipv6": ipv6, + } + ) + with tempfile.NamedTemporaryFile( + mode="w", encoding="utf-8", dir=RUNTIME, prefix=".recovery-access-", delete=False + ) as stream: + json.dump(status, stream, sort_keys=True) + stream.write("\n") + stream.flush() + os.fsync(stream.fileno()) + temporary = Path(stream.name) + temporary.chmod(0o644) + os.replace(temporary, STATUS) + fsync_directory(RUNTIME) + return 0 if operational else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/build/usb/includes.chroot/usr/lib/rigos/rigos-runtime-gate b/build/usb/includes.chroot/usr/lib/rigos/rigos-runtime-gate new file mode 100644 index 00000000..c028d554 --- /dev/null +++ b/build/usb/includes.chroot/usr/lib/rigos/rigos-runtime-gate @@ -0,0 +1,66 @@ +#!/usr/bin/python3 +import argparse +import json +import sys +from pathlib import Path + +MAX_JSON_BYTES = 2 * 1024 * 1024 + + +def emit(outcome: str, reason: str | None = None) -> None: + value = { + "schema": "rigos.runtime-gate/v1", + "outcome": outcome, + "reason": reason, + } + print(json.dumps(value, sort_keys=True), file=sys.stdout if outcome == "allowed" else sys.stderr) + + +def read_object(path: Path) -> dict: + raw = path.read_bytes() + if len(raw) > MAX_JSON_BYTES: + raise ValueError("JSON exceeds size limit") + value = json.loads(raw) + if not isinstance(value, dict): + raise ValueError("JSON root is not an object") + return value + + +def deny(reason: str) -> int: + emit("denied", reason) + return 2 + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--state", type=Path, default=Path("/var/lib/rigos")) + parser.add_argument("--runtime", type=Path, default=Path("/run/rigos")) + args = parser.parse_args() + try: + revision = (args.state / "current").resolve(strict=True).name + status = read_object(args.runtime / "runtime-config-status.json") + config = read_object(args.runtime / "xmrig.json") + except (OSError, ValueError, json.JSONDecodeError): + return deny("runtime_truth_unreadable") + if status.get("schema") != "rigos.runtime-config-status/v1" or status.get("outcome") != "ready": + return deny("runtime_status_invalid") + if status.get("revision") != revision: + return deny("runtime_revision_stale") + pools = config.get("pools") + cpu = config.get("cpu") + if not isinstance(pools, list) or not pools or not isinstance(cpu, dict): + return deny("runtime_config_invalid") + exact_threads = status.get("exact_threads") + profile = status.get("profile") + if exact_threads is not None: + if not isinstance(exact_threads, int) or exact_threads < 1 or not isinstance(profile, str): + return deny("runtime_thread_truth_invalid") + entries = cpu.get(profile) + if not isinstance(entries, list) or len(entries) != exact_threads: + return deny("runtime_thread_profile_mismatch") + emit("allowed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/build/usb/includes.chroot/usr/lib/rigos/rigos-runtime-render b/build/usb/includes.chroot/usr/lib/rigos/rigos-runtime-render new file mode 100644 index 00000000..9e5a0fc2 --- /dev/null +++ b/build/usb/includes.chroot/usr/lib/rigos/rigos-runtime-render @@ -0,0 +1,146 @@ +#!/usr/bin/python3 +import copy +import grp +import hashlib +import json +import os +import tempfile +from pathlib import Path + +STATE = Path(os.environ.get("RIGOS_STATE_PATH", "/var/lib/rigos")) +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" +MAX_JSON_BYTES = 2 * 1024 * 1024 + + +def read_object(path: Path) -> dict: + raw = path.read_bytes() + if len(raw) > MAX_JSON_BYTES: + raise RuntimeError(f"JSON exceeds size limit: {path}") + value = json.loads(raw) + if not isinstance(value, dict): + raise RuntimeError(f"JSON root is not an object: {path}") + return value + + +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 atomic_json(path: Path, value: dict, mode: int, gid: int | None = None) -> None: + path.parent.mkdir(mode=0o755, parents=True, exist_ok=True) + descriptor, name = tempfile.mkstemp(prefix=f".{path.name}-", dir=path.parent) + temporary = Path(name) + try: + os.fchmod(descriptor, mode) + if gid is not None: + os.fchown(descriptor, 0, gid) + with os.fdopen(descriptor, "w", encoding="utf-8", closefd=True) as stream: + json.dump(value, stream, sort_keys=True) + stream.write("\n") + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + path.chmod(mode) + fsync_directory(path.parent) + finally: + temporary.unlink(missing_ok=True) + + +def exact_profile_name(algorithm: str) -> str: + if algorithm in ("rx", "rx/0"): + return "rx" + if algorithm.startswith("rx/"): + return algorithm + raise RuntimeError("explicit threads require a RandomX algorithm") + + +def render() -> tuple[dict, dict, dict]: + current = (STATE / "current").resolve(strict=True) + if current.parent != (STATE / "revisions").resolve(strict=True): + raise RuntimeError("current revision is outside the revisions directory") + policy = read_object(current / "policy.json") + if policy.get("schema") != "rigos.policy/v1": + raise RuntimeError("policy schema mismatch") + sheet_name = policy.get("active_flight_sheet") + if not isinstance(sheet_name, str) or not sheet_name or "/" in sheet_name or ".." in sheet_name: + raise RuntimeError("active flight sheet name is invalid") + sheet = read_object(current / "flight-sheets" / f"{sheet_name}.json") + if sheet.get("schema") != "rigos.flight-sheet/v1" or sheet.get("backend") != "xmrig": + raise RuntimeError("flight sheet schema or backend mismatch") + committed = read_object(current / "xmrig.json") + runtime = copy.deepcopy(committed) + cpu = runtime.get("cpu") + if not isinstance(cpu, dict): + raise RuntimeError("committed XMRig CPU policy is missing") + algorithm = sheet.get("algorithm") + if not isinstance(algorithm, str) or not algorithm: + raise RuntimeError("flight sheet algorithm is invalid") + pools = runtime.get("pools") + if not isinstance(pools, list) or not pools: + raise RuntimeError("committed XMRig pools are missing") + if any(not isinstance(pool, dict) or pool.get("algo") != algorithm for pool in pools): + raise RuntimeError("committed pool algorithm disagrees with the flight sheet") + sheet_cpu = sheet.get("cpu") + if not isinstance(sheet_cpu, dict): + raise RuntimeError("flight sheet CPU policy is missing") + threads = sheet_cpu.get("threads") + profile = None + exact_threads = None + if isinstance(threads, int) and not isinstance(threads, bool): + if not 1 <= threads <= 1024: + raise RuntimeError("explicit thread count is out of range") + profile = exact_profile_name(algorithm) + cpu["max-threads-hint"] = sheet_cpu.get("max_threads_hint", 100) + cpu[profile] = [-1] * threads + 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): + pool.pop("user", None) + pool.pop("pass", None) + http = public.get("http") + if isinstance(http, dict): + http.pop("access-token", None) + 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", + "revision": current.name, + "algorithm": algorithm, + "thread_mode": "exact" if exact_threads is not None else "auto", + "exact_threads": exact_threads, + "profile": profile, + "private_sha256": hashlib.sha256( + (json.dumps(runtime, sort_keys=True) + "\n").encode("utf-8") + ).hexdigest(), + "identity_redacted_public_view": True, + } + return runtime, public, status + + +def main() -> int: + runtime, public, status = render() + gid = None + if os.environ.get("RIGOS_RENDER_SKIP_CHOWN") != "1": + gid = grp.getgrnam("rigos").gr_gid + atomic_json(PRIVATE_CONFIG, runtime, 0o640, gid) + atomic_json(PUBLIC_CONFIG, public, 0o644) + atomic_json(STATUS, status, 0o644) + return 0 + + +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 89304e89..85b9c79b 100644 --- a/build/usb/package-lists/rigos.list.chroot +++ b/build/usb/package-lists/rigos.list.chroot @@ -7,6 +7,7 @@ live-boot live-config linux-image-amd64 network-manager +openssh-server python3 systemd-sysv sudo diff --git a/build/usb/version.env b/build/usb/version.env index 041c82ab..9567e611 100644 --- a/build/usb/version.env +++ b/build/usb/version.env @@ -1,8 +1,8 @@ -RIGOS_PRODUCT_VERSION=0.0.4-alpha.7 -RIGOS_IMAGE_VERSION=0.0.4-alpha.7 +RIGOS_PRODUCT_VERSION=0.0.4-alpha.8 +RIGOS_IMAGE_VERSION=0.0.4-alpha.8 RIGOS_IMAGE_ID=rigos-usb-amd64 RIGOS_IMAGE_CHANNEL=alpha -RIGOS_BUILD_ORDINAL=7 +RIGOS_BUILD_ORDINAL=8 RIGOS_XMRIG_VERSION=6.26.0 RIGOS_XMRIG_ARCHIVE_SHA256=fc6f8ae5f64e4f17481f7e3be29a1c56949f216a998414188003eae1db20c9e5 RIGOS_XMRIG_BINARY_SHA256=b20f39fc00d242e706b6c30367ad811c676e0575050a4ec2f30104b696944b49 diff --git a/crates/rigos-config/tests/alpha8_recovery_gate.rs b/crates/rigos-config/tests/alpha8_recovery_gate.rs new file mode 100644 index 00000000..dce3b59b --- /dev/null +++ b/crates/rigos-config/tests/alpha8_recovery_gate.rs @@ -0,0 +1,79 @@ +use std::fs; +use std::path::PathBuf; +use std::process::Command; +use uuid::Uuid; + +fn repo_path(path: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .join(path) +} + +#[test] +fn recovery_gate_accepts_only_current_persisted_credential_truth() { + let root = std::env::temp_dir().join(format!("rigos-recovery-gate-{}", Uuid::new_v4())); + let runtime = root.join("run"); + let boot_id = root.join("boot-id"); + fs::create_dir_all(&runtime).unwrap(); + fs::write(&boot_id, "boot-test\n").unwrap(); + + let status = runtime.join("recovery-access-status.json"); + let valid = serde_json::json!({ + "schema": "rigos.recovery-access-status/v1", + "boot_id": "boot-test", + "local_console_access": true, + "credential_action": "created", + "credential_persisted": true + }); + fs::write(&status, serde_json::to_vec(&valid).unwrap()).unwrap(); + + let gate = repo_path("build/usb/includes.chroot/usr/lib/rigos/rigos-recovery-access-verify"); + let run = |runtime_path: &PathBuf, boot_path: &PathBuf| { + Command::new("python3") + .arg(&gate) + .env("RIGOS_RUNTIME_PATH", runtime_path) + .env("RIGOS_BOOT_ID_PATH", boot_path) + .status() + .unwrap() + }; + + assert!(run(&runtime, &boot_id).success()); + + let stale = serde_json::json!({ + "schema": "rigos.recovery-access-status/v1", + "boot_id": "old-boot", + "local_console_access": true, + "credential_action": "created", + "credential_persisted": true + }); + fs::write(&status, serde_json::to_vec(&stale).unwrap()).unwrap(); + assert_eq!(run(&runtime, &boot_id).code(), Some(2)); + + let not_persisted = serde_json::json!({ + "schema": "rigos.recovery-access-status/v1", + "boot_id": "boot-test", + "local_console_access": true, + "credential_action": "created", + "credential_persisted": false + }); + fs::write(&status, serde_json::to_vec(¬_persisted).unwrap()).unwrap(); + assert_eq!(run(&runtime, &boot_id).code(), Some(2)); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn recovery_service_accepts_legacy_exit_one_only_with_post_validation() { + let unit = fs::read_to_string(repo_path( + "build/usb/includes.chroot/etc/systemd/system/rigos-recovery-access.service", + )) + .unwrap(); + + assert!(unit.contains( + "Before=rigos-state-ready.service rigos-firstboot.service getty@tty1.service ssh.service" + )); + assert!(unit.contains("SuccessExitStatus=1")); + assert!( + unit.contains("ExecStartPost=/usr/bin/python3 /usr/lib/rigos/rigos-recovery-access-verify") + ); +} diff --git a/crates/rigos-config/tests/recovery_access.rs b/crates/rigos-config/tests/recovery_access.rs index ea30a417..4d6b5772 100644 --- a/crates/rigos-config/tests/recovery_access.rs +++ b/crates/rigos-config/tests/recovery_access.rs @@ -8,6 +8,16 @@ fn recovery_path() -> PathBuf { .join("../../build/usb/includes.chroot/usr/local/sbin/rigos-recovery-access") } +fn alpha8_runtime_check_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../scripts/check-alpha8-runtime.py") +} + +fn repo_path(path: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .join(path) +} + #[test] fn recovery_password_is_persisted_restored_and_redacted() { let root = std::env::temp_dir().join(format!("rigos-recovery-access-{}", Uuid::new_v4())); @@ -119,3 +129,38 @@ assert valid_hash not in json.dumps(json.loads((g['RUNTIME'] / 'recovery-access- let _ = fs::remove_dir_all(&root); assert!(result.success(), "recovery access fixture failed"); } + +#[test] +fn alpha8_runtime_authority_is_exact_and_fail_closed() { + let result = Command::new("python3") + .arg(alpha8_runtime_check_path()) + .status() + .expect("run Alpha8 runtime authority fixture"); + assert!(result.success(), "Alpha8 runtime authority fixture failed"); +} + +#[test] +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("rigos-runtime-render.service")); + assert!(hook.contains("systemctl disable ssh.socket")); + + 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-gate")); + 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", + )) + .expect("read SSH observer override"); + assert!(ssh.contains("Wants=rigos-remote-access-observe.service")); +} diff --git a/scripts/check-alpha8-runtime.py b/scripts/check-alpha8-runtime.py new file mode 100644 index 00000000..cd67fff7 --- /dev/null +++ b/scripts/check-alpha8-runtime.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +import json +import os +import runpy +import subprocess +import tempfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +RENDERER = ROOT / "build/usb/includes.chroot/usr/lib/rigos/rigos-runtime-render" +GATE = ROOT / "build/usb/includes.chroot/usr/lib/rigos/rigos-runtime-gate" +REMOTE_PROBE = ROOT / "build/usb/includes.chroot/usr/lib/rigos/rigos-remote-access-probe" + + +def write(path: Path, value: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(value), encoding="utf-8") + + +def verify_runtime_authority(root: Path) -> None: + state = root / "state" + runtime = root / "run" + revision = state / "revisions/r1" + (revision / "flight-sheets").mkdir(parents=True) + (state / "current").symlink_to(Path("revisions/r1")) + write( + revision / "policy.json", + {"schema": "rigos.policy/v1", "active_flight_sheet": "xmr"}, + ) + write( + revision / "flight-sheets/xmr.json", + { + "schema": "rigos.flight-sheet/v1", + "backend": "xmrig", + "algorithm": "rx/0", + "cpu": { + "threads": 2, + "huge_pages": True, + "max_threads_hint": 100, + }, + }, + ) + write( + revision / "xmrig.json", + { + "cpu": { + "enabled": True, + "huge-pages": True, + "max-threads-hint": 2, + }, + "pools": [{"url": "pool.test:1", "algo": "rx/0"}], + "http": {"enabled": False}, + }, + ) + environment = os.environ.copy() + environment.update( + { + "RIGOS_STATE_PATH": str(state), + "RIGOS_RUNTIME_PATH": str(runtime), + "RIGOS_RENDER_SKIP_CHOWN": "1", + } + ) + subprocess.run(["python3", str(RENDERER)], env=environment, check=True) + config = json.loads((runtime / "xmrig.json").read_text(encoding="utf-8")) + assert config["cpu"]["max-threads-hint"] == 100 + assert config["cpu"]["rx"] == [-1, -1] + status = json.loads( + (runtime / "runtime-config-status.json").read_text(encoding="utf-8") + ) + assert status["thread_mode"] == "exact" + assert status["exact_threads"] == 2 + assert status["profile"] == "rx" + allowed = subprocess.run( + [ + "python3", + str(GATE), + "--state", + str(state), + "--runtime", + str(runtime), + ], + check=False, + ) + assert allowed.returncode == 0 + config["cpu"]["rx"] = [-1] + write(runtime / "xmrig.json", config) + denied = subprocess.run( + [ + "python3", + str(GATE), + "--state", + str(state), + "--runtime", + str(runtime), + ], + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + assert denied.returncode == 2 + + +def verify_remote_truth(root: Path) -> None: + runtime = root / "remote-run" + runtime.mkdir() + boot_id = root / "remote-boot-id" + boot_id.write_text("boot-test\n", encoding="ascii") + status_path = runtime / "recovery-access-status.json" + write( + status_path, + { + "schema": "rigos.recovery-access-status/v1", + "boot_id": "boot-test", + "state_outcome": "ready", + "local_console_access": True, + }, + ) + namespace = runpy.run_path(str(REMOTE_PROBE), run_name="rigos_remote_probe_test") + globals_ = namespace["main"].__globals__ + globals_["RUNTIME"] = runtime + globals_["STATUS"] = status_path + globals_["BOOT_ID"] = boot_id + globals_["unit_state"] = lambda _action, _unit: True + globals_["has_listener"] = lambda path, _port: path.name == "tcp" + assert namespace["main"]() == 0 + observed = json.loads(status_path.read_text(encoding="utf-8")) + assert observed["mode"] == "operational" + assert observed["remote_access"] == "active" + assert observed["ssh_service_enabled"] is True + assert observed["ssh_service_active"] is True + assert observed["ssh_listener_ipv4"] is True + assert observed["ssh_listener_ipv6"] is False + globals_["has_listener"] = lambda _path, _port: False + assert namespace["main"]() == 1 + degraded = json.loads(status_path.read_text(encoding="utf-8")) + assert degraded["mode"] == "recovery" + assert degraded["remote_access"] == "enabled_no_listener" + + +def main() -> int: + with tempfile.TemporaryDirectory(prefix="rigos-alpha8-") as temporary: + root = Path(temporary) + verify_runtime_authority(root) + verify_remote_truth(root) + print("RIGOS Alpha8 runtime and remote truth verification passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/check-alpha8-ssh-hotfix.py b/scripts/check-alpha8-ssh-hotfix.py new file mode 100644 index 00000000..17a154e3 --- /dev/null +++ b/scripts/check-alpha8-ssh-hotfix.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +import hashlib +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +POLICY = ROOT / "build/usb/includes.chroot/etc/ssh/sshd_config.d/00-rigos.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" +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") + return raw.replace(b"\r\n", b"\n").replace(b"\r", b"\n") + + +def main() -> int: + policy = normalized_lf_bytes(POLICY) + observed = hashlib.sha256(policy).hexdigest() + if observed != EXPECTED_POLICY_SHA256: + raise RuntimeError( + f"Alpha8 SSH policy hash mismatch: expected={EXPECTED_POLICY_SHA256} observed={observed}" + ) + packages = PACKAGES.read_text(encoding="utf-8").splitlines() + if "openssh-server" not in packages: + raise RuntimeError("OpenSSH server package is missing") + 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") + 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") + if 'ENTRYPOINT ["/bin/bash", "-c",' not in dockerfile: + raise RuntimeError("builder entrypoint must use a non-login shell") + if '"bash", "-lc"' in dockerfile or '"/bin/bash", "-lc"' in dockerfile: + raise RuntimeError("builder entrypoint must not use a login shell") + if "cargo --version" not in dockerfile or "rustc --version" not in dockerfile: + raise RuntimeError("builder toolchain verification is missing") + + recovery_unit = RECOVERY_UNIT.read_text(encoding="utf-8") + required_unit_lines = ( + "Before=rigos-state-ready.service rigos-firstboot.service getty@tty1.service ssh.service", + "SuccessExitStatus=1", + "ExecStartPost=/usr/bin/python3 /usr/lib/rigos/rigos-recovery-access-verify", + ) + for required in required_unit_lines: + if required not in recovery_unit: + raise RuntimeError(f"recovery access hotfix wiring is missing: {required}") + + recovery_gate = RECOVERY_GATE.read_text(encoding="utf-8") + compile(recovery_gate, str(RECOVERY_GATE), "exec") + for required in ( + 'status.get("boot_id") != boot_id', + 'status.get("local_console_access") is not True', + 'status.get("credential_persisted") is not True', + ): + 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") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())