diff --git a/Cargo.lock b/Cargo.lock index 44b367b8..88b1e7e3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -782,6 +782,21 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "rigos-config" +version = "0.0.1" +dependencies = [ + "chrono", + "clap", + "hex", + "schemars", + "serde", + "serde_json", + "sha2", + "thiserror", + "uuid", +] + [[package]] name = "rigos-contract" version = "0.0.1" @@ -844,6 +859,7 @@ name = "rigos-schema" version = "0.0.1" dependencies = [ "jsonschema", + "rigos-config", "rigos-core", "rigos-machine", "rigos-pool", diff --git a/Cargo.toml b/Cargo.toml index 7a60bac5..5108ed96 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ members = [ "crates/rigos-contract", "crates/rigos-pool", "crates/rigos-state", + "crates/rigos-config", ] resolver = "2" diff --git a/README.md b/README.md index 3794ff18..20b779a5 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Local-first CPU appliance delivered as a bootable USB image. -Current preview is `RIGOS 0.0.4-alpha.4`. +Current development preview is `RIGOS 0.0.4-alpha.5`. The persistent appliance uses a raw MBR disk image for Legacy BIOS and removable-media UEFI boot. @@ -22,9 +22,10 @@ The recovery ISO is stateless and does not grow the state partition. 0.0.4-alpha.2 MBR image reached GRUB ROOT_A systemd and password setup 0.0.4-alpha.3 fixed console order but kept the first boot screen hidden 0.0.4-alpha.4 keeps the first boot screen on tty and captures answers separately +0.0.4-alpha.5 adds local rig profiles and portable XMRig Flight Sheets ``` -Alpha four still requires a new image build, checksum, QEMU boot matrix and physical first-boot completion. +Alpha five is isolated on its development branch. Alpha four physical state validation remains separate. ## Verification diff --git a/build/usb/Dockerfile b/build/usb/Dockerfile index 2ed281f2..9f773489 100644 --- a/build/usb/Dockerfile +++ b/build/usb/Dockerfile @@ -1,4 +1,4 @@ -FROM rust:1.85.1-bookworm +FROM docker.io/library/rust:1.85.1-bookworm RUN apt-get update \ && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ diff --git a/build/usb/bootloaders/grub-pc/live-theme/theme.txt b/build/usb/bootloaders/grub-pc/live-theme/theme.txt index 895cb029..2e75711f 100644 --- a/build/usb/bootloaders/grub-pc/live-theme/theme.txt +++ b/build/usb/bootloaders/grub-pc/live-theme/theme.txt @@ -1,6 +1,6 @@ title-color: "#ffffff" title-font: "Unifont Regular 16" -title-text: "RIGOS Recovery 0.0.4-alpha.4 - Stateless Diagnostics" +title-text: "RIGOS Recovery 0.0.4-alpha.5 - Stateless Diagnostics" message-font: "Unifont Regular 16" terminal-font: "Unifont Regular 16" diff --git a/build/usb/bootloaders/syslinux_common/live.cfg.in b/build/usb/bootloaders/syslinux_common/live.cfg.in index 9764b9f9..d17a6983 100644 --- a/build/usb/bootloaders/syslinux_common/live.cfg.in +++ b/build/usb/bootloaders/syslinux_common/live.cfg.in @@ -1,12 +1,12 @@ label rigos-@FLAVOUR@ - menu label ^RIGOS Recovery 0.0.4-alpha.4 + menu label ^RIGOS Recovery 0.0.4-alpha.5 menu default linux @LINUX@ initrd @INITRD@ append @APPEND_LIVE@ label rigos-@FLAVOUR@-failsafe - menu label RIGOS Recovery 0.0.4-alpha.4 -- ^safe mode + menu label RIGOS Recovery 0.0.4-alpha.5 -- ^safe mode linux @LINUX@ initrd @INITRD@ append @APPEND_LIVE_FAILSAFE@ diff --git a/build/usb/hooks/010-rigos.chroot b/build/usb/hooks/010-rigos.chroot index e5438efc..c3e04f65 100644 --- a/build/usb/hooks/010-rigos.chroot +++ b/build/usb/hooks/010-rigos.chroot @@ -7,8 +7,8 @@ passwd --lock rigosadmin install -d -o rigos -g rigos -m 0750 /var/lib/rigos /run/rigos install -d -m 0755 /usr/lib/rigos -chmod 0755 /usr/local/sbin/rigos-firstboot /usr/lib/rigos/rigos-state-init /usr/lib/rigos/xmrig -systemctl enable NetworkManager.service rigos-state.service rigos-firstboot.service tmp.mount +chmod 0755 /usr/local/sbin/rigos-firstboot /usr/lib/rigos/rigos-state-init /usr/lib/rigos/rigos-config /usr/lib/rigos/xmrig +systemctl enable NetworkManager.service rigos-state.service rigos-profile-apply.service rigos-firstboot.service rigos-miner.service tmp.mount 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-firstboot.service b/build/usb/includes.chroot/etc/systemd/system/rigos-firstboot.service index e7bd63de..0738a1a6 100644 --- a/build/usb/includes.chroot/etc/systemd/system/rigos-firstboot.service +++ b/build/usb/includes.chroot/etc/systemd/system/rigos-firstboot.service @@ -1,9 +1,8 @@ [Unit] Description=RIGOS first-boot configuration -After=network-online.target rigos-state.service +After=network-online.target rigos-state.service rigos-profile-apply.service Wants=network-online.target Requires=rigos-state.service -Conflicts=getty@tty1.service Before=getty@tty1.service ConditionPathExists=!/var/lib/rigos/policy.json @@ -17,7 +16,6 @@ TTYPath=/dev/tty1 TTYReset=yes TTYVHangup=yes TTYVTDisallocate=yes -RemainAfterExit=yes [Install] WantedBy=multi-user.target diff --git a/build/usb/includes.chroot/etc/systemd/system/rigos-miner.service b/build/usb/includes.chroot/etc/systemd/system/rigos-miner.service index 03f8e01d..f93f487e 100644 --- a/build/usb/includes.chroot/etc/systemd/system/rigos-miner.service +++ b/build/usb/includes.chroot/etc/systemd/system/rigos-miner.service @@ -1,6 +1,6 @@ [Unit] Description=RIGOS managed XMRig -After=network-online.target rigos-state.service +After=network-online.target rigos-state.service rigos-profile-apply.service Wants=network-online.target Requires=rigos-state.service ConditionPathExists=/var/lib/rigos/xmrig.json @@ -9,6 +9,7 @@ ConditionPathExists=/var/lib/rigos/xmrig.json Type=simple User=rigos Group=rigos +ExecCondition=/usr/lib/rigos/rigos-config gate ExecStart=/usr/lib/rigos/xmrig --config=/var/lib/rigos/xmrig.json Restart=on-failure RestartSec=5s diff --git a/build/usb/includes.chroot/etc/systemd/system/rigos-profile-apply.service b/build/usb/includes.chroot/etc/systemd/system/rigos-profile-apply.service new file mode 100644 index 00000000..6dc7e7b9 --- /dev/null +++ b/build/usb/includes.chroot/etc/systemd/system/rigos-profile-apply.service @@ -0,0 +1,14 @@ +[Unit] +Description=Apply RIGOS persistent machine profile +After=rigos-state.service +Requires=rigos-state.service +Before=rigos-firstboot.service rigos-miner.service + +[Service] +Type=oneshot +ExecStartPre=/usr/lib/rigos/rigos-config recover +ExecStart=/usr/lib/rigos/rigos-config timezone +RemainAfterExit=yes + +[Install] +WantedBy=multi-user.target diff --git a/build/usb/includes.chroot/etc/systemd/system/rigos-state.service b/build/usb/includes.chroot/etc/systemd/system/rigos-state.service index 4bbbb7ee..5bba5cd9 100644 --- a/build/usb/includes.chroot/etc/systemd/system/rigos-state.service +++ b/build/usb/includes.chroot/etc/systemd/system/rigos-state.service @@ -6,6 +6,8 @@ Before=local-fs.target rigos-firstboot.service rigos-miner.service [Service] Type=oneshot +RuntimeDirectory=rigos +RuntimeDirectoryMode=0755 ExecStart=/usr/lib/rigos/rigos-state-init RemainAfterExit=yes diff --git a/build/usb/includes.chroot/usr/lib/rigos/lsblk-compat b/build/usb/includes.chroot/usr/lib/rigos/lsblk-compat new file mode 100644 index 00000000..c42501cb --- /dev/null +++ b/build/usb/includes.chroot/usr/lib/rigos/lsblk-compat @@ -0,0 +1,122 @@ +#!/usr/bin/python3 + +import json +import os +import re +import subprocess +import sys +from pathlib import Path + +MAJOR_MINOR_RE = re.compile(r"^[0-9]+:[0-9]+$") + + +def runtime_paths() -> tuple[str, Path]: + test_mode = os.environ.get("RIGOS_LSBLK_COMPAT_TEST") == "1" + if test_mode: + real_lsblk = os.environ.get("RIGOS_REAL_LSBLK", "/usr/bin/lsblk") + sysfs_root = Path(os.environ.get("RIGOS_SYSFS_BLOCK_ROOT", "/sys/dev/block")) + return real_lsblk, sysfs_root + return "/usr/bin/lsblk", Path("/sys/dev/block") + + +def remove_partn_column(arguments: list[str]) -> tuple[list[str], bool]: + updated = list(arguments) + requested = False + index = 0 + while index < len(updated): + argument = updated[index] + if argument in {"--output", "-o"}: + if index + 1 >= len(updated): + raise ValueError(f"{argument} requires a value") + columns = updated[index + 1].split(",") + if "PARTN" in columns: + requested = True + columns = [column for column in columns if column != "PARTN"] + updated[index + 1] = ",".join(columns) + index += 2 + continue + if argument.startswith("--output="): + columns = argument.split("=", 1)[1].split(",") + if "PARTN" in columns: + requested = True + columns = [column for column in columns if column != "PARTN"] + updated[index] = "--output=" + ",".join(columns) + index += 1 + return updated, requested + + +def partition_number(device: dict, sysfs_root: Path) -> int: + major_minor = device.get("maj:min") + path = device.get("path", "unknown device") + if not isinstance(major_minor, str) or not MAJOR_MINOR_RE.fullmatch(major_minor): + raise ValueError(f"invalid block identity for {path}") + source = sysfs_root / major_minor / "partition" + try: + raw = source.read_text(encoding="ascii").strip() + except OSError as error: + raise ValueError(f"partition number unavailable for {path} at {source}: {error}") from error + try: + number = int(raw, 10) + except ValueError as error: + raise ValueError(f"invalid partition number for {path} at {source}") from error + if number <= 0 or number > 0xFFFFFFFF: + raise ValueError(f"partition number out of range for {path} at {source}") + return number + + +def add_partition_numbers(devices: list[dict], sysfs_root: Path) -> None: + for device in devices: + if device.get("type") == "part": + device["partn"] = partition_number(device, sysfs_root) + else: + device["partn"] = None + children = device.get("children") + if children is not None: + if not isinstance(children, list): + raise ValueError("lsblk children field is not a list") + add_partition_numbers(children, sysfs_root) + + +def main() -> int: + real_lsblk, sysfs_root = runtime_paths() + try: + arguments, requested_partn = remove_partn_column(sys.argv[1:]) + except ValueError as error: + print(f"lsblk-compat: {error}", file=sys.stderr) + return 2 + + if not requested_partn: + os.execv(real_lsblk, [real_lsblk, *arguments]) + + if "--json" not in arguments and "-J" not in arguments: + print("lsblk-compat: PARTN compatibility requires JSON output", file=sys.stderr) + return 2 + + result = subprocess.run( + [real_lsblk, *arguments], + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + if result.stderr: + sys.stderr.buffer.write(result.stderr) + if result.returncode != 0: + return result.returncode + + try: + document = json.loads(result.stdout) + devices = document.get("blockdevices") + if not isinstance(devices, list): + raise ValueError("lsblk JSON has no blockdevices list") + add_partition_numbers(devices, sysfs_root) + except (json.JSONDecodeError, UnicodeDecodeError, ValueError) as error: + print(f"lsblk-compat: {error}", file=sys.stderr) + return 2 + + json.dump(document, sys.stdout, separators=(",", ":")) + sys.stdout.write("\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/build/usb/includes.chroot/usr/local/sbin/rigos-firstboot b/build/usb/includes.chroot/usr/local/sbin/rigos-firstboot index 8f66ce8e..bc7027fd 100644 --- a/build/usb/includes.chroot/usr/local/sbin/rigos-firstboot +++ b/build/usb/includes.chroot/usr/local/sbin/rigos-firstboot @@ -1,6 +1,5 @@ #!/usr/bin/env python3 import json -import math import os import re import subprocess @@ -8,108 +7,233 @@ import tempfile from pathlib import Path STATE = Path("/var/lib/rigos") +ENGINE = "/usr/lib/rigos/rigos-config" HOST_RE = re.compile(r"^(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)*[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$|^[0-9a-fA-F:]+$") +SLUG_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9-]{0,63}$") def dialog(*args: str) -> str: result = subprocess.run( ["whiptail", "--title", "RIGOS FIRST BOOT", *args], - check=False, - text=True, - stderr=subprocess.PIPE, + check=False, text=True, stderr=subprocess.PIPE, ) if result.returncode != 0: raise SystemExit("Configuration cancelled; reboot to try again.") return result.stderr.strip() -def prompt(label: str, default: str = "") -> str: - return dialog("--inputbox", label, "10", "72", default) +def prompt(label: str, default: str = "", password: bool = False) -> str: + kind = "--passwordbox" if password else "--inputbox" + return dialog(kind, label, "10", "72", default) -def atomic_json(path: Path, value: object, mode: int = 0o640) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) +def confirm(message: str) -> bool: + result = subprocess.run(["whiptail", "--title", "RIGOS FIRST BOOT", "--yesno", message, "14", "72"], check=False) + return result.returncode == 0 + + +def engine(*args: str, stdin: object | None = None) -> tuple[bool, dict]: + raw = None if stdin is None else (json.dumps(stdin) + "\n") + result = subprocess.run([ENGINE, *args], input=raw, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False) + source = result.stdout if result.returncode == 0 else result.stderr try: - with os.fdopen(fd, "w", encoding="utf-8") as stream: - json.dump(value, stream, indent=2, sort_keys=True) - stream.write("\n") - stream.flush() - os.fsync(stream.fileno()) - os.chmod(temporary, mode) - os.replace(temporary, path) - directory = os.open(path.parent, os.O_DIRECTORY) - try: - os.fsync(directory) - finally: - os.close(directory) - finally: - if os.path.exists(temporary): - os.unlink(temporary) + value = json.loads(source.strip().splitlines()[-1]) + except (json.JSONDecodeError, IndexError): + value = {"code": "RIGOS_CONFIG_INVALID_VALUE", "message": "configuration engine failed without a diagnostic"} + return result.returncode == 0, value -def main() -> None: - status_path = Path("/run/rigos/state-status.json") - state_outcome = "stateless" - if status_path.exists(): - try: - state_outcome = json.loads(status_path.read_text(encoding="utf-8"))["outcome"] - except (OSError, KeyError, json.JSONDecodeError): - state_outcome = "stateless" - dialog("--msgbox", f"Persistent state status: {state_outcome}\n\nNo disk override is available when layout verification fails.", "12", "72") - dialog("--msgbox", "Set the local administrator password next. It remains on this machine and is never sent to RIGOS.", "10", "72") - subprocess.run(["passwd", "rigosadmin"], check=True) - dialog("--msgbox", "MINER\nXMRig 6.26.0 — official upstream binary\n\nRIGOS fee: 0%\nUpstream XMRig donation behavior applies.\nRIGOS does not receive or redirect it.", "14", "72") - dialog("--msgbox", "Configure a compatible Stratum pool. RIGOS has no account and no forced pool.", "10", "72") +def diagnostic_text(error: dict) -> str: + parts = [error.get("code", "RIGOS_CONFIG_INVALID_VALUE")] + location = error.get("file") or "rig.conf" + if error.get("line"): + location += f" line {error['line']}" + parts.append(location) + if error.get("key"): + parts.append(f"Key {error['key']}") + parts.append(error.get("message", "Invalid configuration")) + return "\n".join(parts) + + +def manual_proposal() -> tuple[dict, str, str]: node = prompt("Node name", "rig01").strip() - endpoint = prompt("Pool endpoint (host:port)", "gulf.moneroocean.stream:10128").strip() - identity = prompt("Mining identity (wallet or pool username)").strip() + timezone = prompt("IANA timezone", "UTC").strip() + if not Path("/usr/share/zoneinfo", timezone).is_file(): + raise SystemExit("Timezone is not installed or is invalid.") + endpoint = prompt("Pool endpoint host:port", "gulf.moneroocean.stream:10128").strip() + algorithm = prompt("XMRig algorithm", "rx/0").strip() tls = dialog("--menu", "TLS policy", "12", "60", "2", "required", "TLS enabled", "disabled", "Plain Stratum") - threads_text = prompt("CPU threads (0 = automatic)", "0").strip() - - if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9-]{0,62}", node): - raise SystemExit("Invalid node name.") + threads = prompt("CPU threads auto or 1 to 1024", "auto").strip() + huge_pages = dialog("--menu", "Huge pages", "12", "60", "2", "enabled", "Enable huge pages", "disabled", "Disable huge pages") + start = dialog("--menu", "Miner start policy", "12", "64", "2", "on_boot", "Start automatically on boot", "manual", "Manual start only") + alias = prompt("Local mining identity alias", "main-xmr").strip() + if not SLUG_RE.fullmatch(node) or not SLUG_RE.fullmatch(alias): + raise SystemExit("Invalid node name or identity alias.") if endpoint.count(":") != 1: raise SystemExit("Endpoint must use host:port syntax.") host, port_text = endpoint.rsplit(":", 1) if not HOST_RE.fullmatch(host) or not port_text.isdigit() or not 1 <= int(port_text) <= 65535: raise SystemExit("Invalid pool endpoint.") - if not identity or any(character.isspace() for character in identity): - raise SystemExit("Mining identity cannot be empty or contain whitespace.") - if not threads_text.isdigit() or int(threads_text) > 1024: - raise SystemExit("CPU threads must be between 0 and 1024.") - - policy = { - "schema": "rigos.policy/v1", - "node_name": node, - "pool": { - "profile": "custom-stratum", - "endpoints": [{"host": host.lower(), "port": int(port_text)}], - "tls": tls, - "authentication": {"type": "mining_identity", "identity": identity}, - "backend": "xmrig", - "algorithm": "auto", + if threads != "auto" and (not threads.isdigit() or not 1 <= int(threads) <= 1024): + raise SystemExit("Invalid CPU thread count.") + proposal = { + "schema": "rigos.config-proposal/v1", + "profile": {"node_name": node, "timezone": timezone, "flight_source": "interactive", "flight_ref": None, "miner_start_mode": start}, + "flight_sheet": { + "schema": "rigos.flight-sheet/v1", "name": "manual-xmrig", "coin": "XMR", "backend": "xmrig", "algorithm": algorithm, + "pools": [{"host": host.lower(), "port": int(port_text), "tls": tls == "required", "priority": 0}], + "identity_ref": alias, "worker_template": "{node_name}", + "cpu": {"threads": threads if threads == "auto" else int(threads), "huge_pages": huge_pages == "enabled", "max_threads_hint": 100}, }, - "cpu": {"threads": int(threads_text), "huge_pages": True}, - } - pool_url = f"{host}:{port_text}" - xmrig = { - "autosave": False, - "background": False, - "cpu": {"enabled": True, "huge-pages": True}, - "pools": [{"url": pool_url, "user": identity, "pass": node, "tls": tls == "required", "keepalive": True}], - "api": {"worker-id": node}, - "http": {"enabled": False}, + "provenance": None, "source_sha256": "manual", } - if int(threads_text) > 0: - logical_cpus = os.cpu_count() or 1 - xmrig["cpu"]["max-threads-hint"] = min(100, max(1, math.ceil(int(threads_text) * 100 / logical_cpus))) - - atomic_json(STATE / "policy.json", policy) - atomic_json(STATE / "xmrig.json", xmrig) - subprocess.run(["chown", "rigos:rigos", STATE / "policy.json", STATE / "xmrig.json"], check=True) - subprocess.run(["systemctl", "enable", "--now", "rigos-miner.service"], check=True) - dialog("--msgbox", "Configuration saved atomically. XMRig has been started.", "9", "64") + return proposal, alias, "" + + +def prepare_import(path: str) -> tuple[dict, bool]: + while True: + ok, result = engine("prepare", "--output", path) + if ok: + return json.loads(Path(path).read_text(encoding="utf-8")), False + if result.get("code") == "RIGOS_CONFIG_FILE_MISSING": + return manual_proposal()[0], True + if result.get("code") == "RIGOS_FLIGHT_SHEET_MISSING" and "interactive" in result.get("message", ""): + found, choices = engine("discover") + if found: + items = [("manual", "Configure manually"), ("none", "Do not configure mining now")] + items += [(f"native:{name}", f"Native {name}") for name in choices.get("native", [])] + items += [(f"import:{name}", f"Import {name}") for name in choices.get("import", [])] + flat = [value for pair in items for value in pair] + selected = dialog("--menu", "Select Flight Sheet", "20", "76", str(min(12, len(items))), *flat) + if selected == "manual": + return manual_proposal()[0], True + if selected == "none": + raise SystemExit("Mining left unconfigured. Reboot to configure later.") + source, reference = selected.split(":", 1) + ok, prepared = engine("prepare", "--output", path, "--flight-source", source, "--flight-ref", reference) + if ok: + return json.loads(Path(path).read_text(encoding="utf-8")), False + result = prepared + action = dialog("--menu", diagnostic_text(result), "20", "76", "4", "retry", "Retry import", "manual", "Continue with manual setup", "diagnostics", "Show diagnostics", "reboot", "Reboot") + if action == "retry": + continue + if action == "diagnostics": + dialog("--msgbox", diagnostic_text(result), "18", "76") + continue + if action == "reboot": + subprocess.run(["systemctl", "reboot"], check=False) + raise SystemExit(0) + if confirm("Discard invalid rigos.conf for this boot and continue manually\n\nThe source file will not be changed or deleted."): + return manual_proposal()[0], True + + +def resolve_identity(proposal: dict) -> dict: + requested = proposal["flight_sheet"]["identity_ref"] + identity_dir = STATE / "identities" + candidates = [] + if identity_dir.is_dir(): + for path in sorted(identity_dir.glob("*.json"))[:64]: + try: + record = json.loads(path.read_text(encoding="utf-8")) + if record.get("schema") == "rigos.identity/v1": + candidates.append(record) + except (OSError, json.JSONDecodeError): + continue + external = (proposal.get("provenance") or {}).get("external_reference") + mapping_path = STATE / "external-identity-map.json" + if external and mapping_path.is_file(): + try: + mapping_document = json.loads(mapping_path.read_text(encoding="utf-8")) + mappings = mapping_document.get("mappings", []) if mapping_document.get("schema") == "rigos.external-identity-map/v1" else [] + except (OSError, json.JSONDecodeError): + mappings = [] + mapping = next((item for item in mappings if item.get("source") == external.get("source") and item.get("external_type") == external.get("external_type") and item.get("external_value") == external.get("external_value")), None) + mapped_identity = next((item for item in candidates if mapping and item.get("alias") == mapping.get("identity_ref")), None) + if mapped_identity: + source_status = "same source hash" if mapping.get("confirmed_source_sha256") == proposal.get("source_sha256") else "source hash changed" + if confirm(f"Reuse confirmed external identity mapping\n\nLocal alias {mapped_identity['alias']}\n{source_status}\nValue suffix ****{mapped_identity['value'][-4:]}"): + proposal["flight_sheet"]["identity_ref"] = mapped_identity["alias"] + return mapped_identity + exact = next((item for item in candidates if item.get("alias") == requested), None) + if exact and confirm(f"Reuse local identity alias {requested}\n\nValue suffix {exact['value'][-6:].rjust(len(exact['value'][-6:]) + 4, '*')}"): + proposal["flight_sheet"]["identity_ref"] = exact["alias"] + return exact + aliases = [(f"existing:{item['alias']}", f"Use {item['alias']} suffix ****{item['value'][-4:]}") for item in candidates] + choices = aliases + [("new", "Add a new local identity"), ("none", "Leave mining unconfigured"), ("reboot", "Cancel and reboot")] + selected = dialog("--menu", "Mining identity is unresolved", "18", "76", str(min(12, len(choices))), *[value for pair in choices for value in pair]) + if selected.startswith("existing:"): + alias = selected.split(":", 1)[1] + selected_identity = next(item for item in candidates if item["alias"] == alias) + if not confirm(f"Use local identity alias {alias} for this Flight Sheet\n\nValue suffix ****{selected_identity['value'][-4:]}"): + return resolve_identity(proposal) + proposal["flight_sheet"]["identity_ref"] = alias + return selected_identity + if selected == "none": + subprocess.run(["systemctl", "stop", "rigos-miner.service"], check=False) + raise SystemExit("Mining remains unconfigured and stopped.") + if selected == "reboot": + subprocess.run(["systemctl", "reboot"], check=False) + raise SystemExit(0) + alias = prompt("Local identity alias", requested if SLUG_RE.fullmatch(requested) else "main-xmr").strip() + value = prompt("Mining identity value", password=True).strip() + if not SLUG_RE.fullmatch(alias) or not value or len(value) > 512 or any(character.isspace() or ord(character) < 32 for character in value): + raise SystemExit("Invalid local mining identity.") + proposal["flight_sheet"]["identity_ref"] = alias + return {"schema": "rigos.identity/v1", "alias": alias, "kind": "mining_identity", "value": value, "created_locally": True} + + +def ensure_administrator_password() -> None: + status = subprocess.run(["passwd", "--status", "rigosadmin"], check=False, text=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) + fields = status.stdout.split() + if status.returncode == 0 and len(fields) >= 2 and fields[1] == "P": + return + dialog("--msgbox", "Set the local administrator password next. It remains on this machine and enables local recovery diagnostics.", "11", "72") + subprocess.run(["passwd", "rigosadmin"], check=True) + verified = subprocess.run(["passwd", "--status", "rigosadmin"], check=False, text=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) + verified_fields = verified.stdout.split() + if verified.returncode != 0 or len(verified_fields) < 2 or verified_fields[1] != "P": + raise SystemExit("Local administrator password was not established.") + + +def main() -> None: + ensure_administrator_password() + status_path = Path("/run/rigos/state-status.json") + try: + state_outcome = json.loads(status_path.read_text(encoding="utf-8"))["outcome"] + except (OSError, KeyError, json.JSONDecodeError): + state_outcome = "unavailable" + if state_outcome not in {"ready", "grown"}: + dialog("--msgbox", f"Persistent state status: {state_outcome}\n\nConfiguration and mining are blocked until the verified USB state is ready.", "13", "72") + raise SystemExit(1) + + with tempfile.NamedTemporaryFile(prefix="rigos-proposal-", dir="/run/rigos", delete=False) as stream: + proposal_path = stream.name + os.unlink(proposal_path) + try: + proposal, _manual = prepare_import(proposal_path) + identity = resolve_identity(proposal) + sheet = proposal["flight_sheet"] + profile = proposal["profile"] + primary = sheet["pools"][0] + summary = ( + f"Node {profile['node_name']}\nFlight Sheet {sheet['name']}\n" + f"Pool {primary['host']}:{primary['port']}\nFallback pools {len(sheet['pools']) - 1}\n" + f"Algorithm {sheet['algorithm']}\nTLS {'enabled' if primary['tls'] else 'disabled'}\n" + f"Identity {identity['alias']} suffix ****{identity['value'][-4:]}\n" + f"Start policy {profile['miner_start_mode']}\nHuge pages {sheet['cpu']['huge_pages']}" + ) + if not confirm(summary + "\n\nCommit this configuration locally?"): + raise SystemExit("Configuration not confirmed; no changes were written.") + ok, result = engine("transact", stdin={"proposal": proposal, "identity": identity}) + if not ok: + dialog("--msgbox", f"Configuration transaction failed\n{diagnostic_text(result)}\n\nPrevious runtime state was restored or mining was left stopped for recovery.", "18", "76") + raise SystemExit(1) + dialog("--msgbox", f"Configuration saved atomically.\nMiner started: {result.get('miner_started', False)}", "10", "64") + finally: + try: + os.unlink(proposal_path) + except FileNotFoundError: + pass if __name__ == "__main__": diff --git a/build/usb/version.env b/build/usb/version.env index 81adc923..44e1a3f4 100644 --- a/build/usb/version.env +++ b/build/usb/version.env @@ -1,8 +1,8 @@ -RIGOS_PRODUCT_VERSION=0.0.4-alpha.4 -RIGOS_IMAGE_VERSION=0.0.4-alpha.4 +RIGOS_PRODUCT_VERSION=0.0.4-alpha.5 +RIGOS_IMAGE_VERSION=0.0.4-alpha.5 RIGOS_IMAGE_ID=rigos-usb-amd64 RIGOS_IMAGE_CHANNEL=alpha -RIGOS_BUILD_ORDINAL=4 +RIGOS_BUILD_ORDINAL=5 RIGOS_XMRIG_VERSION=6.26.0 RIGOS_XMRIG_ARCHIVE_SHA256=fc6f8ae5f64e4f17481f7e3be29a1c56949f216a998414188003eae1db20c9e5 RIGOS_XMRIG_BINARY_SHA256=b20f39fc00d242e706b6c30367ad811c676e0575050a4ec2f30104b696944b49 diff --git a/configs/flight-sheets/xmr-ssl.json b/configs/flight-sheets/xmr-ssl.json new file mode 100644 index 00000000..f7405c3f --- /dev/null +++ b/configs/flight-sheets/xmr-ssl.json @@ -0,0 +1,22 @@ +{ + "schema": "rigos.flight-sheet/v1", + "name": "xmr-ssl", + "coin": "XMR", + "backend": "xmrig", + "algorithm": "rx/0", + "pools": [ + { + "host": "gulf.moneroocean.stream", + "port": 10001, + "tls": true, + "priority": 0 + } + ], + "identity_ref": "main-xmr", + "worker_template": "{node_name}", + "cpu": { + "threads": "auto", + "huge_pages": true, + "max_threads_hint": 100 + } +} diff --git a/configs/rigos.conf.example b/configs/rigos.conf.example new file mode 100644 index 00000000..22a7f3a6 --- /dev/null +++ b/configs/rigos.conf.example @@ -0,0 +1,10 @@ +# RIGOS machine profile version one +# Copy to /rigos/rig.conf on the EFI_SYSTEM partition. +# Data only. Never sourced as shell. + +RIGOS_CONFIG_VERSION=1 +NODE_NAME=rig01 +TIMEZONE=Asia/Bangkok +FLIGHT_SOURCE=native +FLIGHT_REF=xmr-ssl +MINER_START_MODE=on_boot diff --git a/crates/rigos-config/Cargo.toml b/crates/rigos-config/Cargo.toml new file mode 100644 index 00000000..261b9cc5 --- /dev/null +++ b/crates/rigos-config/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "rigos-config" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[dependencies] +chrono.workspace = true +clap.workspace = true +hex.workspace = true +serde.workspace = true +serde_json.workspace = true +schemars.workspace = true +sha2.workspace = true +thiserror.workspace = true +uuid.workspace = true + +[lib] +name = "rigos_config" + +[[bin]] +name = "rigos-config" +path = "src/main.rs" diff --git a/crates/rigos-config/src/lib.rs b/crates/rigos-config/src/lib.rs new file mode 100644 index 00000000..c00f8f41 --- /dev/null +++ b/crates/rigos-config/src/lib.rs @@ -0,0 +1,1501 @@ +#![forbid(unsafe_code)] + +use chrono::{SecondsFormat, Utc}; +use schemars::JsonSchema; +use serde::de::{self, MapAccess, Visitor}; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value, json}; +use sha2::{Digest, Sha256}; +use std::{ + collections::{BTreeMap, BTreeSet}, + fs::{self, File, OpenOptions}, + io::Write, + path::{Component, Path, PathBuf}, + process::Command, +}; +use thiserror::Error; +use uuid::Uuid; + +pub const MAX_CONFIG_BYTES: usize = 64 * 1024; +pub const MAX_SHEET_BYTES: usize = 1024 * 1024; + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum FlightSource { + Native, + Import, + Interactive, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum MinerStartMode { + Manual, + OnBoot, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +pub struct RigProfile { + pub node_name: String, + pub timezone: String, + pub flight_source: FlightSource, + pub flight_ref: Option, + pub miner_start_mode: MinerStartMode, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +pub struct Pool { + pub host: String, + pub port: u16, + pub tls: bool, + pub priority: u16, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +#[serde(untagged)] +pub enum Threads { + Auto(String), + Count(u16), +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +pub struct CpuPolicy { + pub threads: Threads, + pub huge_pages: bool, + pub max_threads_hint: u8, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct FlightSheet { + pub schema: String, + pub name: String, + pub coin: String, + pub backend: String, + pub algorithm: String, + pub pools: Vec, + pub identity_ref: String, + pub worker_template: String, + pub cpu: CpuPolicy, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExternalReference { + pub source: String, + pub external_type: String, + pub external_value: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ImportProvenance { + pub schema: String, + pub source_type: String, + pub source_filename: String, + pub source_sha256: String, + pub imported_at: String, + pub warnings: Vec, + pub external_reference: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Proposal { + pub schema: String, + pub profile: RigProfile, + pub flight_sheet: FlightSheet, + pub provenance: Option, + pub source_sha256: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct IdentityRecord { + pub schema: String, + pub alias: String, + pub kind: String, + pub value: String, + pub created_locally: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ConfigDiagnostic { + pub code: String, + pub file: Option, + pub line: Option, + pub key: Option, + pub message: String, +} + +#[derive(Debug, Error)] +#[error("{diagnostic:?}")] +pub struct ConfigError { + pub diagnostic: ConfigDiagnostic, +} + +fn error( + code: &str, + file: Option<&str>, + line: Option, + key: Option<&str>, + message: impl Into, +) -> ConfigError { + ConfigError { + diagnostic: ConfigDiagnostic { + code: code.into(), + file: file.map(str::to_owned), + line, + key: key.map(str::to_owned), + message: message.into(), + }, + } +} + +pub fn parse_rig_profile(bytes: &[u8]) -> Result { + if bytes.len() > MAX_CONFIG_BYTES { + return Err(error( + "RIGOS_CONFIG_FILE_TOO_LARGE", + Some("rig.conf"), + None, + None, + "configuration exceeds 64 KiB", + )); + } + let text = std::str::from_utf8(bytes).map_err(|_| { + error( + "RIGOS_CONFIG_INVALID_UTF8", + Some("rig.conf"), + None, + None, + "configuration is not valid UTF-8", + ) + })?; + let supported = [ + "RIGOS_CONFIG_VERSION", + "NODE_NAME", + "TIMEZONE", + "FLIGHT_SOURCE", + "FLIGHT_REF", + "MINER_START_MODE", + ]; + let reserved = [ + "WATCHDOG_ENABLED", + "AUTO_START", + "ACTIVE_FLIGHT_SHEET", + "IMPORT_FLIGHT_SHEET", + ]; + let mut values = BTreeMap::new(); + for (index, raw) in text.lines().enumerate() { + let line = index + 1; + let value = raw.trim(); + if value.is_empty() || value.starts_with('#') { + continue; + } + if value.contains("$(`") || value.contains("$(") || value.contains('`') { + return Err(error( + "RIGOS_CONFIG_SYNTAX", + Some("rig.conf"), + Some(line), + None, + "shell expansion is forbidden", + )); + } + let (key, field) = value.split_once('=').ok_or_else(|| { + error( + "RIGOS_CONFIG_SYNTAX", + Some("rig.conf"), + Some(line), + None, + "expected KEY=VALUE", + ) + })?; + if key.is_empty() + || !key.bytes().all(|b| b.is_ascii_uppercase() || b == b'_') + || field.trim() != field + || field.contains(['\r', '\n']) + { + return Err(error( + "RIGOS_CONFIG_SYNTAX", + Some("rig.conf"), + Some(line), + Some(key), + "invalid key or unquoted value", + )); + } + if reserved.contains(&key) { + return Err(error( + "RIGOS_CONFIG_UNSUPPORTED_KEY", + Some("rig.conf"), + Some(line), + Some(key), + format!("unsupported key {key} in config version 1"), + )); + } + if !supported.contains(&key) { + return Err(error( + "RIGOS_CONFIG_UNKNOWN_KEY", + Some("rig.conf"), + Some(line), + Some(key), + format!("unknown key {key}"), + )); + } + if values + .insert(key.to_owned(), (field.to_owned(), line)) + .is_some() + { + return Err(error( + "RIGOS_CONFIG_DUPLICATE_KEY", + Some("rig.conf"), + Some(line), + Some(key), + format!("duplicate key {key}"), + )); + } + } + let get = |key: &str| { + values.get(key).map(|v| v.0.as_str()).ok_or_else(|| { + error( + "RIGOS_CONFIG_INVALID_VALUE", + Some("rig.conf"), + None, + Some(key), + format!("missing required key {key}"), + ) + }) + }; + if get("RIGOS_CONFIG_VERSION")? != "1" { + return Err(error( + "RIGOS_CONFIG_VERSION_UNSUPPORTED", + Some("rig.conf"), + values.get("RIGOS_CONFIG_VERSION").map(|v| v.1), + Some("RIGOS_CONFIG_VERSION"), + "only config version 1 is supported", + )); + } + let node_name = get("NODE_NAME")?.to_owned(); + if !valid_slug(&node_name, 63) { + return Err(error( + "RIGOS_CONFIG_INVALID_VALUE", + Some("rig.conf"), + values.get("NODE_NAME").map(|v| v.1), + Some("NODE_NAME"), + "node name is not hostname safe", + )); + } + let timezone = get("TIMEZONE")?.to_owned(); + if !valid_timezone_name(&timezone) { + return Err(error( + "RIGOS_CONFIG_INVALID_VALUE", + Some("rig.conf"), + values.get("TIMEZONE").map(|v| v.1), + Some("TIMEZONE"), + "invalid timezone name", + )); + } + let flight_source = match get("FLIGHT_SOURCE")? { + "native" => FlightSource::Native, + "import" => FlightSource::Import, + "interactive" => FlightSource::Interactive, + _ => { + return Err(error( + "RIGOS_CONFIG_INVALID_VALUE", + Some("rig.conf"), + values.get("FLIGHT_SOURCE").map(|v| v.1), + Some("FLIGHT_SOURCE"), + "expected native import or interactive", + )); + } + }; + let flight_ref = values.get("FLIGHT_REF").map(|v| v.0.clone()); + match (&flight_source, &flight_ref) { + (FlightSource::Interactive, None) => {} + (FlightSource::Interactive, Some(_)) => { + return Err(error( + "RIGOS_CONFIG_INVALID_VALUE", + Some("rig.conf"), + values.get("FLIGHT_REF").map(|v| v.1), + Some("FLIGHT_REF"), + "interactive source forbids FLIGHT_REF", + )); + } + (_, None) => { + return Err(error( + "RIGOS_CONFIG_INVALID_VALUE", + Some("rig.conf"), + None, + Some("FLIGHT_REF"), + "selected source requires FLIGHT_REF", + )); + } + (FlightSource::Native, Some(name)) if !valid_slug(name, 64) => { + return Err(error( + "RIGOS_CONFIG_INVALID_VALUE", + Some("rig.conf"), + values.get("FLIGHT_REF").map(|v| v.1), + Some("FLIGHT_REF"), + "invalid flight sheet slug", + )); + } + (FlightSource::Import, Some(name)) if !safe_json_basename(name) => { + return Err(error( + "RIGOS_CONFIG_INVALID_VALUE", + Some("rig.conf"), + values.get("FLIGHT_REF").map(|v| v.1), + Some("FLIGHT_REF"), + "invalid import basename", + )); + } + _ => {} + } + let miner_start_mode = match get("MINER_START_MODE")? { + "manual" => MinerStartMode::Manual, + "on_boot" => MinerStartMode::OnBoot, + _ => { + return Err(error( + "RIGOS_CONFIG_INVALID_VALUE", + Some("rig.conf"), + values.get("MINER_START_MODE").map(|v| v.1), + Some("MINER_START_MODE"), + "expected manual or on_boot", + )); + } + }; + Ok(RigProfile { + node_name, + timezone, + flight_source, + flight_ref, + miner_start_mode, + }) +} + +pub fn parse_flight_sheet(bytes: &[u8], filename: &str) -> Result { + if bytes.len() > MAX_SHEET_BYTES { + return Err(error( + "RIGOS_CONFIG_FILE_TOO_LARGE", + Some(filename), + None, + None, + "flight sheet exceeds 1 MiB", + )); + } + let mut sheet: FlightSheet = serde_json::from_slice(bytes).map_err(|_| { + error( + "RIGOS_FLIGHT_SHEET_INVALID", + Some(filename), + None, + None, + "invalid RIGOS flight sheet JSON", + ) + })?; + validate_sheet(&mut sheet, filename)?; + Ok(sheet) +} + +fn validate_sheet(sheet: &mut FlightSheet, filename: &str) -> Result<(), ConfigError> { + if sheet.schema != "rigos.flight-sheet/v1" + || !valid_slug(&sheet.name, 64) + || sheet.backend != "xmrig" + || sheet.coin.is_empty() + || sheet.coin.len() > 16 + || sheet.algorithm.is_empty() + || sheet.algorithm.len() > 64 + { + return Err(error( + "RIGOS_FLIGHT_SHEET_INVALID", + Some(filename), + None, + None, + "unsupported schema backend or metadata", + )); + } + if !valid_slug(&sheet.identity_ref, 64) + || !matches!( + sheet.worker_template.as_str(), + "{node_name}" | "{node_name}.rig" + ) + { + return Err(error( + "RIGOS_FLIGHT_SHEET_INVALID", + Some(filename), + None, + Some("identity_ref"), + "invalid identity reference or worker template", + )); + } + if sheet.pools.is_empty() || sheet.pools.len() > 16 { + return Err(error( + "RIGOS_FLIGHT_SHEET_INVALID", + Some(filename), + None, + Some("pools"), + "one to sixteen pools are required", + )); + } + let mut priorities = BTreeSet::new(); + for pool in &mut sheet.pools { + pool.host.make_ascii_lowercase(); + if !valid_host(&pool.host) || pool.port == 0 || !priorities.insert(pool.priority) { + return Err(error( + "RIGOS_FLIGHT_SHEET_INVALID", + Some(filename), + None, + Some("pools"), + "invalid host port or duplicate priority", + )); + } + } + sheet.pools.sort_by_key(|pool| pool.priority); + match &sheet.cpu.threads { + Threads::Auto(value) if value == "auto" => {} + Threads::Count(value) if (1..=1024).contains(value) => {} + _ => { + return Err(error( + "RIGOS_FLIGHT_SHEET_INVALID", + Some(filename), + None, + Some("cpu.threads"), + "threads must be auto or 1 through 1024", + )); + } + } + if !(1..=100).contains(&sheet.cpu.max_threads_hint) { + return Err(error( + "RIGOS_FLIGHT_SHEET_INVALID", + Some(filename), + None, + Some("cpu.max_threads_hint"), + "max threads hint must be 1 through 100", + )); + } + Ok(()) +} + +pub fn import_hive_style( + bytes: &[u8], + filename: &str, +) -> Result<(FlightSheet, ImportProvenance), ConfigError> { + if bytes.len() > MAX_SHEET_BYTES { + return Err(error( + "RIGOS_CONFIG_FILE_TOO_LARGE", + Some(filename), + None, + None, + "import exceeds 1 MiB", + )); + } + let root: Value = serde_json::from_slice(bytes).map_err(|_| { + error( + "RIGOS_FLIGHT_SHEET_INVALID", + Some(filename), + None, + None, + "invalid external JSON", + ) + })?; + let object = root.as_object().ok_or_else(|| { + error( + "RIGOS_FLIGHT_SHEET_INVALID", + Some(filename), + None, + None, + "external sheet must be an object", + ) + })?; + let workload = hive_workload(object, filename)?; + let (miner_config, nested_miner_config) = hive_miner_config(object, workload, filename)?; + for forbidden in [ + "hive_host_url", + "api_host_url", + "rig_id", + "farm_id", + "rig_passwd", + "hssh_srv", + "token", + "password", + ] { + if object + .keys() + .chain(workload.keys()) + .chain(miner_config.keys()) + .any(|key| key.eq_ignore_ascii_case(forbidden)) + { + return Err(error( + "RIGOS_FLIGHT_SHEET_INVALID", + Some(filename), + None, + Some(forbidden), + "dangerous cloud or credential field is forbidden", + )); + } + } + let miner = string_field(workload, &["miner", "miner_name"]).unwrap_or("xmrig"); + if !miner.to_ascii_lowercase().contains("xmrig") { + return Err(error( + "RIGOS_FLIGHT_SHEET_INVALID", + Some(filename), + None, + Some("miner"), + "only XMRig imports are supported", + )); + } + let name = string_field(workload, &["name"]) + .or_else(|| string_field(object, &["name"])) + .unwrap_or("imported-xmrig") + .to_ascii_lowercase() + .replace([' ', '_'], "-"); + let coin = string_field(workload, &["coin"]) + .or_else(|| string_field(object, &["coin"])) + .unwrap_or("XMR") + .to_owned(); + let algorithm = string_field(miner_config, &["algo", "algorithm"]) + .filter(|value| !value.is_empty()) + .or_else(|| (!nested_miner_config).then_some("auto")) + .ok_or_else(|| { + error( + "RIGOS_FLIGHT_SHEET_INVALID", + Some(filename), + None, + Some("algo"), + "miner_config algo is required", + ) + })? + .to_owned(); + if nested_miner_config { + if string_field(miner_config, &["url"]) != Some("%URL%") { + return Err(error( + "RIGOS_FLIGHT_SHEET_INVALID", + Some(filename), + None, + Some("url"), + "miner_config url must be %URL%", + )); + } + if string_field(miner_config, &["template"]) != Some("%WAL%") { + return Err(error( + "RIGOS_FLIGHT_SHEET_INVALID", + Some(filename), + None, + Some("template"), + "miner_config template must be %WAL%", + )); + } + } + if let Some(pass) = string_field(miner_config, &["pass"]) { + if !matches!(pass, "x" | "%WORKER_NAME%" | "{node_name}") { + return Err(error( + "RIGOS_FLIGHT_SHEET_INVALID", + Some(filename), + None, + Some("pass"), + "embedded pool password is forbidden", + )); + } + } + let worker_source = if nested_miner_config { + string_field(miner_config, &["pass"]) + } else { + string_field(miner_config, &["template"]) + }; + let worker_template = match worker_source { + None | Some("x") | Some("%WORKER_NAME%") | Some("{node_name}") => "{node_name}", + Some("%WORKER_NAME%.rig") | Some("{node_name}.rig") => "{node_name}.rig", + Some(_) => { + return Err(error( + "RIGOS_FLIGHT_SHEET_INVALID", + Some(filename), + None, + Some(if nested_miner_config { + "pass" + } else { + "template" + }), + "unsupported worker placeholder", + )); + } + }; + let urls = strict_string_list( + workload.get("pool_urls").or_else(|| workload.get("pools")), + filename, + "pool_urls", + )?; + let tls_values = strict_bool_list(workload.get("pool_ssl"), urls.len(), filename)?; + let mut pools = Vec::new(); + for (index, raw) in urls.iter().enumerate() { + let (clean, scheme_tls) = if let Some(value) = raw.strip_prefix("stratum+ssl://") { + (value, Some(true)) + } else if let Some(value) = raw.strip_prefix("stratum+tcp://") { + (value, Some(false)) + } else { + (raw.as_str(), None) + }; + if scheme_tls.is_some_and(|value| value != tls_values[index]) { + return Err(error( + "RIGOS_FLIGHT_SHEET_INVALID", + Some(filename), + None, + Some("pool_ssl"), + "pool URL scheme conflicts with pool_ssl", + )); + } + let (host, port) = clean.rsplit_once(':').ok_or_else(|| { + error( + "RIGOS_FLIGHT_SHEET_INVALID", + Some(filename), + None, + Some("pool_urls"), + "pool endpoint must be host:port", + ) + })?; + pools.push(Pool { + host: host.to_owned(), + port: port.parse().map_err(|_| { + error( + "RIGOS_FLIGHT_SHEET_INVALID", + Some(filename), + None, + Some("pool_urls"), + "invalid pool port", + ) + })?, + tls: tls_values[index], + priority: index as u16, + }); + } + let wal = workload.get("wal_id").map(value_as_reference); + let identity_ref = wal + .as_deref() + .map(|v| format!("hive-wal-{v}")) + .unwrap_or_else(|| "unresolved".into()); + let mut huge_pages = true; + let mut max_threads_hint = 100; + for key in ["cpu_config", "user_config"] { + if let Some(value) = miner_config.get(key) { + let fragment = if let Some(text) = value.as_str() { + Value::Object(parse_json_member_fragment(text, filename, key)?) + } else { + value.clone() + }; + let cpu = fragment.get("cpu").and_then(Value::as_object).or_else(|| { + (key == "cpu_config") + .then(|| fragment.as_object()) + .flatten() + }); + if let Some(cpu) = cpu { + if let Some(v) = cpu.get("huge-pages").and_then(Value::as_bool) { + huge_pages = v; + } + if let Some(v) = cpu.get("max-threads-hint").and_then(Value::as_u64) { + max_threads_hint = u8::try_from(v).unwrap_or(0); + } + } + } + } + let mut sheet = FlightSheet { + schema: "rigos.flight-sheet/v1".into(), + name, + coin, + backend: "xmrig".into(), + algorithm, + pools, + identity_ref, + worker_template: worker_template.into(), + cpu: CpuPolicy { + threads: Threads::Auto("auto".into()), + huge_pages, + max_threads_hint, + }, + }; + validate_sheet(&mut sheet, filename)?; + let digest = hex::encode(Sha256::digest(bytes)); + let provenance = ImportProvenance { + schema: "rigos.import-provenance/v1".into(), + source_type: "external-flight-sheet".into(), + source_filename: filename.into(), + source_sha256: digest, + imported_at: Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true), + warnings: lifecycle_warnings(object, workload), + external_reference: wal.map(|value| ExternalReference { + source: "hive-style".into(), + external_type: "wal_id".into(), + external_value: value, + }), + }; + Ok((sheet, provenance)) +} + +fn parse_json_member_fragment( + input: &str, + filename: &str, + key: &str, +) -> Result, ConfigError> { + let trimmed = input.trim(); + if trimmed.is_empty() || trimmed.contains('\0') { + return Err(fragment_error(filename, key)); + } + if trimmed.starts_with('{') { + return parse_unique_object(trimmed).map_err(|_| fragment_error(filename, key)); + } + let wrapped = format!("{{{trimmed}}}"); + if let Ok(value) = parse_unique_object(&wrapped) { + return Ok(value); + } + let normalized = + insert_top_level_member_commas(trimmed).ok_or_else(|| fragment_error(filename, key))?; + parse_unique_object(&format!("{{{normalized}}}")).map_err(|_| fragment_error(filename, key)) +} + +fn fragment_error(filename: &str, key: &str) -> ConfigError { + error( + "RIGOS_FLIGHT_SHEET_INVALID", + Some(filename), + None, + Some(key), + "embedded config is not a strict JSON object or member fragment", + ) +} + +struct UniqueObject(Map); + +impl<'de> Deserialize<'de> for UniqueObject { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + struct UniqueObjectVisitor; + impl<'de> Visitor<'de> for UniqueObjectVisitor { + type Value = UniqueObject; + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("a JSON object with unique keys") + } + fn visit_map(self, mut access: A) -> Result + where + A: MapAccess<'de>, + { + let mut output = Map::new(); + while let Some((key, value)) = access.next_entry::()? { + if output.insert(key.clone(), value).is_some() { + return Err(de::Error::custom(format!("duplicate member {key}"))); + } + } + Ok(UniqueObject(output)) + } + } + deserializer.deserialize_map(UniqueObjectVisitor) + } +} + +fn parse_unique_object(input: &str) -> Result, serde_json::Error> { + serde_json::from_str::(input).map(|value| value.0) +} + +fn insert_top_level_member_commas(input: &str) -> Option { + let bytes = input.as_bytes(); + let mut output = Vec::with_capacity(input.len() + 8); + let mut index = 0; + let mut depth = 0_u32; + let mut in_string = false; + let mut escaped = false; + while index < bytes.len() { + let byte = bytes[index]; + if in_string { + output.push(byte); + if escaped { + escaped = false; + } else if byte == b'\\' { + escaped = true; + } else if byte == b'"' { + in_string = false; + } + index += 1; + continue; + } + match byte { + b'"' => { + in_string = true; + output.push(b'"'); + index += 1; + } + b'{' | b'[' => { + depth = depth.checked_add(1)?; + output.push(byte); + index += 1; + } + b'}' | b']' => { + depth = depth.checked_sub(1)?; + output.push(byte); + index += 1; + } + value if value.is_ascii_whitespace() && depth == 0 => { + let start = index; + while index < bytes.len() && bytes[index].is_ascii_whitespace() { + index += 1; + } + output.extend_from_slice(&bytes[start..index]); + if index < bytes.len() && bytes[index] == b'"' { + let previous = output + .iter() + .rev() + .copied() + .find(|value| !value.is_ascii_whitespace())?; + if matches!(previous, b'}' | b']' | b'"' | b'0'..=b'9' | b'e' | b'l') { + output.push(b','); + } + } + } + _ => { + output.push(byte); + index += 1; + } + } + } + if in_string || depth != 0 { + None + } else { + String::from_utf8(output).ok() + } +} + +pub fn build_runtime( + proposal: &Proposal, + identity: &IdentityRecord, +) -> Result<(Value, Value), ConfigError> { + validate_identity(identity)?; + if identity.alias != proposal.flight_sheet.identity_ref + && proposal.flight_sheet.identity_ref != "unresolved" + && !proposal.flight_sheet.identity_ref.starts_with("hive-wal-") + { + return Err(error( + "RIGOS_FLIGHT_SHEET_UNRESOLVED_IDENTITY", + None, + None, + Some("identity_ref"), + "selected identity alias does not match the flight sheet", + )); + } + let sheet = &proposal.flight_sheet; + let worker = sheet + .worker_template + .replace("{node_name}", &proposal.profile.node_name); + let policy = json!({"schema":"rigos.policy/v1","node_name":proposal.profile.node_name,"timezone":proposal.profile.timezone,"active_flight_sheet":sheet.name,"identity_ref":identity.alias,"miner_start_mode":proposal.profile.miner_start_mode}); + let pools: Vec<_> = sheet.pools.iter().map(|pool| json!({"url":format!("{}:{}", pool.host, pool.port),"user":identity.value,"pass":worker,"tls":pool.tls,"keepalive":true,"algo":sheet.algorithm})).collect(); + let mut cpu = Map::from_iter([ + ("enabled".into(), Value::Bool(true)), + ("huge-pages".into(), Value::Bool(sheet.cpu.huge_pages)), + ]); + cpu.insert("max-threads-hint".into(), json!(sheet.cpu.max_threads_hint)); + if let Threads::Count(count) = sheet.cpu.threads { + cpu.insert("max-threads-hint".into(), json!(count)); + } + let xmrig = json!({"autosave":false,"background":false,"cpu":cpu,"pools":pools,"api":{"worker-id":worker},"http":{"enabled":false}}); + Ok((policy, xmrig)) +} + +pub fn commit_revision( + state: &Path, + proposal: &Proposal, + identity: &IdentityRecord, +) -> Result { + let (policy, xmrig) = build_runtime(proposal, identity)?; + let revisions = state.join("revisions"); + fs::create_dir_all(&revisions).map_err(io_error)?; + let id = Uuid::new_v4().to_string(); + let staging = revisions.join(format!(".{id}.staging")); + let final_dir = revisions.join(&id); + fs::create_dir(&staging).map_err(io_error)?; + let result = (|| { + write_json(&staging.join("policy.json"), &policy, 0o640)?; + write_json(&staging.join("xmrig.json"), &xmrig, 0o640)?; + fs::create_dir(staging.join("flight-sheets")).map_err(io_error)?; + write_json( + &staging + .join("flight-sheets") + .join(format!("{}.json", proposal.flight_sheet.name)), + &proposal.flight_sheet, + 0o640, + )?; + fs::create_dir(staging.join("identities")).map_err(io_error)?; + let prior_identities = state.join("current/identities"); + if prior_identities.is_dir() { + for entry in fs::read_dir(&prior_identities).map_err(io_error)? { + let entry = entry.map_err(io_error)?; + let name = entry.file_name().to_string_lossy().into_owned(); + let metadata = fs::symlink_metadata(entry.path()).map_err(io_error)?; + if metadata.file_type().is_file() + && !metadata.file_type().is_symlink() + && safe_json_basename(&name) + && name != format!("{}.json", identity.alias) + { + fs::copy(entry.path(), staging.join("identities").join(name)) + .map_err(io_error)?; + } + } + } + write_json( + &staging + .join("identities") + .join(format!("{}.json", identity.alias)), + identity, + 0o600, + )?; + if let Some(provenance) = &proposal.provenance { + write_json(&staging.join("import-provenance.json"), provenance, 0o640)?; + } + if let Some(reference) = proposal + .provenance + .as_ref() + .and_then(|value| value.external_reference.as_ref()) + { + write_json( + &staging.join("external-identity-map.json"), + &json!({ + "schema":"rigos.external-identity-map/v1", + "mappings":[{ + "source":reference.source, + "external_type":reference.external_type, + "external_value":reference.external_value, + "identity_ref":identity.alias, + "confirmed_source_sha256":proposal.source_sha256, + }] + }), + 0o600, + )?; + } + let owned = Command::new("chown") + .args(["-R", "root:rigos"]) + .arg(&staging) + .status() + .map_err(io_error)?; + if !owned.success() { + return Err(error( + "RIGOS_CONFIG_INVALID_VALUE", + None, + None, + None, + "failed to restrict revision ownership", + )); + } + File::open(&staging) + .and_then(|f| f.sync_all()) + .map_err(io_error)?; + fs::rename(&staging, &final_dir).map_err(io_error)?; + if let Ok(previous_target) = fs::read_link(state.join("current")) { + let previous_link = state.join(format!(".previous-{id}")); + create_relative_symlink(previous_target, &previous_link)?; + fs::rename(previous_link, state.join("previous")).map_err(io_error)?; + } + let link = state.join(format!(".current-{id}")); + create_relative_symlink(Path::new("revisions").join(&id), &link)?; + fs::rename(&link, state.join("current")).map_err(io_error)?; + ensure_public_link(state, "policy.json", Path::new("current/policy.json"))?; + ensure_public_link(state, "xmrig.json", Path::new("current/xmrig.json"))?; + ensure_public_link(state, "flight-sheets", Path::new("current/flight-sheets"))?; + ensure_public_link(state, "identities", Path::new("current/identities"))?; + ensure_public_link( + state, + "external-identity-map.json", + Path::new("current/external-identity-map.json"), + )?; + File::open(state) + .and_then(|f| f.sync_all()) + .map_err(io_error)?; + Ok(id.clone()) + })(); + if result.is_err() { + let _ = fs::remove_dir_all(&staging); + } + result +} + +pub fn safe_json_basename(name: &str) -> bool { + !name.starts_with('.') + && name.ends_with(".json") + && name.len() <= 128 + && !name.contains("..") + && name + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'_')) +} + +pub fn safe_join(root: &Path, name: &str) -> Result { + if !safe_json_basename(name) + || Path::new(name) + .components() + .any(|c| !matches!(c, Component::Normal(_))) + { + return Err(error( + "RIGOS_CONFIG_INVALID_VALUE", + None, + None, + Some("FLIGHT_REF"), + "unsafe filename", + )); + } + Ok(root.join(name)) +} + +pub fn validate_identity(identity: &IdentityRecord) -> Result<(), ConfigError> { + if identity.schema != "rigos.identity/v1" + || identity.kind != "mining_identity" + || !valid_slug(&identity.alias, 64) + || identity.value.is_empty() + || identity.value.len() > 512 + || identity + .value + .chars() + .any(|c| c.is_whitespace() || c.is_control()) + { + return Err(error( + "RIGOS_FLIGHT_SHEET_UNRESOLVED_IDENTITY", + None, + None, + Some("identity_ref"), + "invalid local mining identity", + )); + } + Ok(()) +} + +fn valid_slug(value: &str, max: usize) -> bool { + !value.is_empty() + && value.len() <= max + && value + .bytes() + .next() + .is_some_and(|b| b.is_ascii_alphanumeric()) + && value + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-') +} +fn valid_timezone_name(value: &str) -> bool { + !value.is_empty() + && value.len() <= 128 + && !value.starts_with('/') + && !value.contains("..") + && value.split('/').all(|p| { + !p.is_empty() + && p.bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'+')) + }) +} +fn valid_host(value: &str) -> bool { + !value.is_empty() + && value.len() <= 253 + && (value.contains(':') + || value.split('.').all(|p| { + !p.is_empty() + && p.len() <= 63 + && p.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-') + })) +} +fn string_field<'a>(object: &'a Map, names: &[&str]) -> Option<&'a str> { + names + .iter() + .find_map(|name| object.get(*name).and_then(Value::as_str)) +} +fn value_as_reference(value: &Value) -> String { + value + .as_str() + .map(str::to_owned) + .unwrap_or_else(|| value.to_string()) +} +fn hive_workload<'a>( + envelope: &'a Map, + filename: &str, +) -> Result<&'a Map, ConfigError> { + let Some(items) = envelope.get("items") else { + return Ok(envelope); + }; + let items = items.as_array().ok_or_else(|| { + error( + "RIGOS_FLIGHT_SHEET_INVALID", + Some(filename), + None, + Some("items"), + "items must be an array containing exactly one workload", + ) + })?; + if items.len() != 1 { + return Err(error( + "RIGOS_FLIGHT_SHEET_INVALID", + Some(filename), + None, + Some("items"), + "exactly one Hive workload item is required", + )); + } + items[0].as_object().ok_or_else(|| { + error( + "RIGOS_FLIGHT_SHEET_INVALID", + Some(filename), + None, + Some("items"), + "the Hive workload item must be an object", + ) + }) +} + +fn hive_miner_config<'a>( + envelope: &Map, + workload: &'a Map, + filename: &str, +) -> Result<(&'a Map, bool), ConfigError> { + if !envelope.contains_key("items") { + return Ok((workload, false)); + } + let config = workload + .get("miner_config") + .and_then(Value::as_object) + .ok_or_else(|| { + error( + "RIGOS_FLIGHT_SHEET_INVALID", + Some(filename), + None, + Some("miner_config"), + "the Hive workload requires exactly one miner_config object", + ) + })?; + let allowed = [ + "algo", + "url", + "pass", + "template", + "cpu_config", + "user_config", + ]; + if let Some(unknown) = config.keys().find(|key| !allowed.contains(&key.as_str())) { + return Err(error( + "RIGOS_FLIGHT_SHEET_INVALID", + Some(filename), + None, + Some(unknown), + "unsupported miner_config field", + )); + } + Ok((config, true)) +} + +fn strict_string_list( + value: Option<&Value>, + filename: &str, + key: &str, +) -> Result, ConfigError> { + let invalid = || { + error( + "RIGOS_FLIGHT_SHEET_INVALID", + Some(filename), + None, + Some(key), + format!("{key} must contain only strings and cannot be empty"), + ) + }; + match value { + Some(Value::String(value)) if !value.is_empty() => Ok(vec![value.clone()]), + Some(Value::Array(values)) if !values.is_empty() => values + .iter() + .map(|value| { + value + .as_str() + .filter(|value| !value.is_empty()) + .map(str::to_owned) + .ok_or_else(invalid) + }) + .collect(), + _ => Err(invalid()), + } +} + +fn strict_bool_list( + value: Option<&Value>, + count: usize, + filename: &str, +) -> Result, ConfigError> { + let invalid = || { + error( + "RIGOS_FLIGHT_SHEET_INVALID", + Some(filename), + None, + Some("pool_ssl"), + "pool_ssl must be one boolean or a boolean array matching pool_urls", + ) + }; + match value { + Some(Value::Bool(value)) => Ok(vec![*value; count]), + Some(Value::Array(values)) if values.len() == count => values + .iter() + .map(|value| value.as_bool().ok_or_else(invalid)) + .collect(), + _ => Err(invalid()), + } +} + +fn lifecycle_warnings(envelope: &Map, workload: &Map) -> Vec { + ["autostart", "enabled", "start_on_boot"] + .into_iter() + .filter(|key| envelope.contains_key(*key) || workload.contains_key(*key)) + .map(|key| format!("lifecycle field {key} was not imported")) + .collect() +} +fn io_error(error_value: std::io::Error) -> ConfigError { + error( + "RIGOS_CONFIG_INVALID_VALUE", + None, + None, + None, + format!("local state operation failed: {error_value}"), + ) +} +fn write_json(path: &Path, value: &T, _mode: u32) -> Result<(), ConfigError> { + let mut options = OpenOptions::new(); + options.create_new(true).write(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(_mode); + } + let mut file = options.open(path).map_err(io_error)?; + serde_json::to_writer_pretty(&mut file, value).map_err(|e| { + error( + "RIGOS_CONFIG_INVALID_VALUE", + None, + None, + None, + format!("JSON write failed: {e}"), + ) + })?; + file.write_all(b"\n") + .and_then(|_| file.sync_all()) + .map_err(io_error) +} +#[cfg(unix)] +fn create_relative_symlink(target: PathBuf, link: &Path) -> Result<(), ConfigError> { + std::os::unix::fs::symlink(target, link).map_err(io_error) +} +#[cfg(not(unix))] +fn create_relative_symlink(_target: PathBuf, _link: &Path) -> Result<(), ConfigError> { + Err(error( + "RIGOS_CONFIG_INVALID_VALUE", + None, + None, + None, + "atomic revisions require Unix symlinks", + )) +} +fn ensure_public_link(state: &Path, name: &str, target: &Path) -> Result<(), ConfigError> { + let link = state.join(name); + if !link.exists() && fs::symlink_metadata(&link).is_err() { + create_relative_symlink(target.to_path_buf(), &link)?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + fn profile(source: &str, reference: &str) -> Vec { + format!("RIGOS_CONFIG_VERSION=1\nNODE_NAME=rig01\nTIMEZONE=Asia/Bangkok\nFLIGHT_SOURCE={source}\n{reference}MINER_START_MODE=manual\n").into_bytes() + } + #[test] + fn parses_strict_profile() { + assert_eq!( + parse_rig_profile(&profile("native", "FLIGHT_REF=xmr-ssl\n")) + .unwrap() + .node_name, + "rig01" + ); + } + #[test] + fn duplicate_key_reports_line() { + let e = parse_rig_profile( + &[ + profile("native", "FLIGHT_REF=xmr-ssl\n"), + b"NODE_NAME=x\n".to_vec(), + ] + .concat(), + ) + .unwrap_err(); + assert_eq!(e.diagnostic.code, "RIGOS_CONFIG_DUPLICATE_KEY"); + assert_eq!(e.diagnostic.line, Some(7)); + } + #[test] + fn rejects_reserved_and_shell_content() { + assert_eq!( + parse_rig_profile( + &[ + profile("native", "FLIGHT_REF=xmr-ssl\n"), + b"WATCHDOG_ENABLED=0\n".to_vec() + ] + .concat() + ) + .unwrap_err() + .diagnostic + .code, + "RIGOS_CONFIG_UNSUPPORTED_KEY" + ); + assert_eq!( + parse_rig_profile(&profile("native", "FLIGHT_REF=$(id)\n")) + .unwrap_err() + .diagnostic + .code, + "RIGOS_CONFIG_SYNTAX" + ); + } + #[test] + fn selection_matrix_is_strict() { + assert!(parse_rig_profile(&profile("interactive", "")).is_ok()); + assert!(parse_rig_profile(&profile("interactive", "FLIGHT_REF=x\n")).is_err()); + assert!(parse_rig_profile(&profile("import", "")).is_err()); + } + #[test] + fn filenames_are_basenames() { + assert!(safe_json_basename("sheet-1.json")); + assert!(!safe_json_basename("../sheet.json")); + assert!(!safe_json_basename("dir/sheet.json")); + } + #[test] + fn hive_wallet_id_is_never_runtime_identity() { + let raw = br#"{"name":"XMR","miner":"xmrig","pool_urls":["pool.example:443"],"pool_ssl":true,"wal_id":"fixture-wallet-ref"}"#; + let (sheet, provenance) = import_hive_style(raw, "fixture.json").unwrap(); + assert_eq!(sheet.identity_ref, "hive-wal-fixture-wallet-ref"); + assert_eq!( + provenance.external_reference.unwrap().external_value, + "fixture-wallet-ref" + ); + } + #[test] + fn identity_is_redacted_from_policy() { + let p = Proposal { + schema: "rigos.config-proposal/v1".into(), + profile: parse_rig_profile(&profile("native", "FLIGHT_REF=xmr-ssl\n")).unwrap(), + flight_sheet: FlightSheet { + schema: "rigos.flight-sheet/v1".into(), + name: "xmr-ssl".into(), + coin: "XMR".into(), + backend: "xmrig".into(), + algorithm: "rx/0".into(), + pools: vec![Pool { + host: "pool.example".into(), + port: 443, + tls: true, + priority: 0, + }], + identity_ref: "main-xmr".into(), + worker_template: "{node_name}".into(), + cpu: CpuPolicy { + threads: Threads::Auto("auto".into()), + huge_pages: true, + max_threads_hint: 100, + }, + }, + provenance: None, + source_sha256: "x".into(), + }; + let i = IdentityRecord { + schema: "rigos.identity/v1".into(), + alias: "main-xmr".into(), + kind: "mining_identity".into(), + value: "SECRET".into(), + created_locally: true, + }; + let (policy, xmrig) = build_runtime(&p, &i).unwrap(); + assert!(!policy.to_string().contains("SECRET")); + assert!(xmrig.to_string().contains("SECRET")); + } + + #[test] + fn flight_sheet_rejects_lifecycle_and_duplicate_pool_priority() { + let lifecycle = br#"{"schema":"rigos.flight-sheet/v1","name":"xmr","coin":"XMR","backend":"xmrig","algorithm":"rx/0","pools":[{"host":"pool.example","port":443,"tls":true,"priority":0}],"identity_ref":"main-xmr","worker_template":"{node_name}","cpu":{"threads":"auto","huge_pages":true,"max_threads_hint":100},"autostart":true}"#; + assert_eq!( + parse_flight_sheet(lifecycle, "x.json") + .unwrap_err() + .diagnostic + .code, + "RIGOS_FLIGHT_SHEET_INVALID" + ); + let duplicate = lifecycle + .windows(lifecycle.len() - 18) + .next() + .unwrap_or(lifecycle); + assert!(parse_flight_sheet(duplicate, "x.json").is_err()); + } + + #[test] + fn huge_pages_false_reaches_runtime_config() { + let mut proposal = Proposal { + schema: "rigos.config-proposal/v1".into(), + profile: parse_rig_profile(&profile("native", "FLIGHT_REF=xmr-ssl\n")).unwrap(), + flight_sheet: FlightSheet { + schema: "rigos.flight-sheet/v1".into(), + name: "xmr-ssl".into(), + coin: "XMR".into(), + backend: "xmrig".into(), + algorithm: "rx/0".into(), + pools: vec![Pool { + host: "pool.example".into(), + port: 443, + tls: true, + priority: 0, + }], + identity_ref: "main-xmr".into(), + worker_template: "{node_name}".into(), + cpu: CpuPolicy { + threads: Threads::Auto("auto".into()), + huge_pages: false, + max_threads_hint: 100, + }, + }, + provenance: None, + source_sha256: "x".into(), + }; + proposal.flight_sheet.cpu.huge_pages = false; + let identity = IdentityRecord { + schema: "rigos.identity/v1".into(), + alias: "main-xmr".into(), + kind: "mining_identity".into(), + value: "private-value".into(), + created_locally: true, + }; + let (_, xmrig) = build_runtime(&proposal, &identity).unwrap(); + assert_eq!(xmrig["cpu"]["huge-pages"], false); + } + + #[test] + fn importer_rejects_cloud_credentials_and_reports_lifecycle() { + let secret = + br#"{"miner":"xmrig","pool_urls":["pool.example:443"],"rig_passwd":"do-not-copy"}"#; + assert!(import_hive_style(secret, "fixture.json").is_err()); + let lifecycle = br#"{"miner":"xmrig","pool_urls":["pool.example:443"],"pool_ssl":false,"autostart":true}"#; + let (_, provenance) = import_hive_style(lifecycle, "fixture.json").unwrap(); + assert_eq!(provenance.warnings.len(), 1); + assert!(!serde_json::to_string(&provenance).unwrap().contains("true")); + } + + #[test] + fn strict_member_fragments_accept_real_shapes() { + let cpu = parse_json_member_fragment( + "\"huge-pages\": true,\n\"max-threads-hint\": 75", + "fixture.json", + "cpu_config", + ) + .unwrap(); + assert_eq!(cpu["huge-pages"], true); + let user = parse_json_member_fragment( + "\"cpu\": {\"note\": \"brace } ไทย and newline\\n stay data\"}\n\"api\": {\"worker-id\": \"fixture\"}", + "fixture.json", + "user_config", + ) + .unwrap(); + assert!(user["cpu"].is_object()); + assert!(user["api"].is_object()); + } + + #[test] + fn strict_member_fragments_reject_duplicates_and_trailing_data() { + for input in [ + "\"cpu\": {}\n\"cpu\": {}", + "{\"cpu\":{}} trailing", + "\"cpu\": {} garbage", + "\"cpu\": {", + ] { + assert!(parse_json_member_fragment(input, "fixture.json", "user_config").is_err()); + } + } +} diff --git a/crates/rigos-config/src/lib_entry.rs b/crates/rigos-config/src/lib_entry.rs new file mode 100644 index 00000000..a4766623 --- /dev/null +++ b/crates/rigos-config/src/lib_entry.rs @@ -0,0 +1,324 @@ +#![forbid(unsafe_code)] + +use rigos_config::{ConfigDiagnostic, ConfigError, FlightSheet, ImportProvenance}; +use serde_json::{Map, Value}; +use sha2::{Digest, Sha256}; + +const MAX_HIVE_HUGEPAGES: u64 = 1_048_576; + +pub fn import_hive_style( + bytes: &[u8], + filename: &str, +) -> Result<(FlightSheet, ImportProvenance), ConfigError> { + let root: Value = serde_json::from_slice(bytes) + .map_err(|_| compat_error(filename, None, "invalid external JSON"))?; + let envelope = root + .as_object() + .ok_or_else(|| compat_error(filename, None, "external sheet must be an object"))?; + + if !envelope.contains_key("items") { + return rigos_config::import_hive_style(bytes, filename); + } + + let items = envelope + .get("items") + .and_then(Value::as_array) + .ok_or_else(|| compat_error(filename, Some("items"), "items must be an array"))?; + if items.len() != 1 { + return Err(compat_error( + filename, + Some("items"), + "exactly one Hive workload item is required", + )); + } + let workload = items[0].as_object().ok_or_else(|| { + compat_error( + filename, + Some("items"), + "the Hive workload item must be an object", + ) + })?; + let miner_config = workload + .get("miner_config") + .and_then(Value::as_object) + .ok_or_else(|| { + compat_error( + filename, + Some("miner_config"), + "the Hive workload requires exactly one miner_config object", + ) + })?; + + validate_keys(miner_config, filename)?; + validate_cpu(miner_config.get("cpu"), filename)?; + validate_fork(miner_config.get("fork"), filename)?; + let hugepages = parse_hugepages(miner_config.get("hugepages"), filename)?; + let cpu_policy = explicit_huge_pages(miner_config.get("cpu_config"), filename, "cpu_config")?; + let user_policy = + explicit_huge_pages(miner_config.get("user_config"), filename, "user_config")?; + + if let (Some(cpu_value), Some(user_value)) = (cpu_policy, user_policy) { + if cpu_value != user_value { + return Err(compat_error( + filename, + Some("hugepages"), + "cpu_config and user_config disagree about huge pages", + )); + } + } + let explicit_policy = cpu_policy.or(user_policy); + if let (Some(count), Some(enabled)) = (hugepages, explicit_policy) { + if (count > 0) != enabled { + return Err(compat_error( + filename, + Some("hugepages"), + "hugepages conflicts with embedded huge-pages policy", + )); + } + } + + let mut sanitized = root.clone(); + let sanitized_config = sanitized + .as_object_mut() + .and_then(|object| object.get_mut("items")) + .and_then(Value::as_array_mut) + .and_then(|values| values.first_mut()) + .and_then(Value::as_object_mut) + .and_then(|object| object.get_mut("miner_config")) + .and_then(Value::as_object_mut) + .ok_or_else(|| { + compat_error( + filename, + Some("miner_config"), + "miner_config changed during normalization", + ) + })?; + sanitized_config.remove("cpu"); + sanitized_config.remove("fork"); + sanitized_config.remove("hugepages"); + + let normalized = serde_json::to_vec(&sanitized) + .map_err(|_| compat_error(filename, None, "external sheet normalization failed"))?; + let (mut sheet, mut provenance) = rigos_config::import_hive_style(&normalized, filename)?; + if let Some(count) = hugepages { + sheet.cpu.huge_pages = count > 0; + } + provenance.source_sha256 = hex::encode(Sha256::digest(bytes)); + Ok((sheet, provenance)) +} + +fn validate_keys(config: &Map, filename: &str) -> Result<(), ConfigError> { + const ALLOWED: &[&str] = &[ + "algo", + "url", + "pass", + "template", + "cpu_config", + "user_config", + "cpu", + "fork", + "hugepages", + ]; + if let Some(unknown) = config.keys().find(|key| !ALLOWED.contains(&key.as_str())) { + return Err(compat_error( + filename, + Some(unknown), + "unsupported miner_config field", + )); + } + Ok(()) +} + +fn validate_cpu(value: Option<&Value>, filename: &str) -> Result<(), ConfigError> { + match value { + None | Some(Value::Bool(true)) => Ok(()), + Some(Value::Number(number)) if number.as_u64() == Some(1) => Ok(()), + _ => Err(compat_error( + filename, + Some("cpu"), + "miner_config cpu must be integer 1 or boolean true", + )), + } +} + +fn validate_fork(value: Option<&Value>, filename: &str) -> Result<(), ConfigError> { + match value { + None => Ok(()), + Some(Value::String(value)) if value == "xmrig" => Ok(()), + _ => Err(compat_error( + filename, + Some("fork"), + "miner_config fork must be xmrig", + )), + } +} + +fn parse_hugepages(value: Option<&Value>, filename: &str) -> Result, ConfigError> { + match value { + None => Ok(None), + Some(Value::Number(number)) => match number.as_u64() { + Some(value) if value <= MAX_HIVE_HUGEPAGES => Ok(Some(value)), + _ => Err(compat_error( + filename, + Some("hugepages"), + "miner_config hugepages is outside the supported range", + )), + }, + _ => Err(compat_error( + filename, + Some("hugepages"), + "miner_config hugepages must be a non-negative integer", + )), + } +} + +fn explicit_huge_pages( + value: Option<&Value>, + filename: &str, + key: &str, +) -> Result, ConfigError> { + let Some(value) = value else { + return Ok(None); + }; + if let Value::String(text) = value { + if !text.contains("\"huge-pages\"") { + return Ok(None); + } + } + + let parsed = match value { + Value::Object(object) => Value::Object(object.clone()), + Value::String(text) => Value::Object(parse_member_fragment(text, filename, key)?), + _ => { + return Err(compat_error( + filename, + Some(key), + "embedded miner config must be an object or member fragment", + )); + } + }; + let object = parsed.as_object().ok_or_else(|| { + compat_error( + filename, + Some(key), + "embedded miner config must be an object", + ) + })?; + let cpu = object + .get("cpu") + .and_then(Value::as_object) + .unwrap_or(object); + match cpu.get("huge-pages") { + None => Ok(None), + Some(Value::Bool(value)) => Ok(Some(*value)), + Some(_) => Err(compat_error( + filename, + Some(key), + "embedded huge-pages must be boolean", + )), + } +} + +fn parse_member_fragment( + input: &str, + filename: &str, + key: &str, +) -> Result, ConfigError> { + let trimmed = input.trim(); + let direct = if trimmed.starts_with('{') { + trimmed.to_owned() + } else { + format!("{{{trimmed}}}") + }; + if let Ok(value) = serde_json::from_str::(&direct) { + return value + .as_object() + .cloned() + .ok_or_else(|| compat_error(filename, Some(key), "embedded config must be an object")); + } + let normalized = insert_member_commas(trimmed) + .ok_or_else(|| compat_error(filename, Some(key), "embedded config is malformed"))?; + serde_json::from_str::(&format!("{{{normalized}}}")) + .ok() + .and_then(|value| value.as_object().cloned()) + .ok_or_else(|| compat_error(filename, Some(key), "embedded config is malformed")) +} + +fn insert_member_commas(input: &str) -> Option { + let bytes = input.as_bytes(); + let mut output = Vec::with_capacity(input.len() + 8); + let mut index = 0; + let mut depth = 0_u32; + let mut in_string = false; + let mut escaped = false; + while index < bytes.len() { + let byte = bytes[index]; + if in_string { + output.push(byte); + if escaped { + escaped = false; + } else if byte == b'\\' { + escaped = true; + } else if byte == b'"' { + in_string = false; + } + index += 1; + continue; + } + match byte { + b'"' => { + in_string = true; + output.push(byte); + index += 1; + } + b'{' | b'[' => { + depth = depth.checked_add(1)?; + output.push(byte); + index += 1; + } + b'}' | b']' => { + depth = depth.checked_sub(1)?; + output.push(byte); + index += 1; + } + value if value.is_ascii_whitespace() && depth == 0 => { + let start = index; + while index < bytes.len() && bytes[index].is_ascii_whitespace() { + index += 1; + } + output.extend_from_slice(&bytes[start..index]); + if index < bytes.len() && bytes[index] == b'"' { + let previous = output + .iter() + .rev() + .copied() + .find(|value| !value.is_ascii_whitespace())?; + if matches!(previous, b'}' | b']' | b'"' | b'0'..=b'9' | b'e' | b'l') { + output.push(b','); + } + } + } + _ => { + output.push(byte); + index += 1; + } + } + } + if in_string || depth != 0 { + None + } else { + String::from_utf8(output).ok() + } +} + +fn compat_error(filename: &str, key: Option<&str>, message: &str) -> ConfigError { + ConfigError { + diagnostic: ConfigDiagnostic { + code: "RIGOS_FLIGHT_SHEET_INVALID".into(), + file: Some(filename.into()), + line: None, + key: key.map(str::to_owned), + message: message.into(), + }, + } +} diff --git a/crates/rigos-config/src/main.rs b/crates/rigos-config/src/main.rs new file mode 100644 index 00000000..ece89114 --- /dev/null +++ b/crates/rigos-config/src/main.rs @@ -0,0 +1,1009 @@ +#![forbid(unsafe_code)] + +#[path = "lib_entry.rs"] +mod compatibility; + +use clap::{Parser, Subcommand}; +use rigos_config::{ + ConfigDiagnostic, ConfigError, FlightSource, IdentityRecord, MinerStartMode, Proposal, + commit_revision, parse_flight_sheet, parse_rig_profile, safe_join, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use std::{ + fs, + io::{self, Read}, + path::{Path, PathBuf}, + process::{Command, ExitCode}, +}; +use uuid::Uuid; + +#[derive(Parser)] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + Prepare { + #[arg(long, default_value = "/run/rigos/boot-device.json")] + attestation: PathBuf, + #[arg(long, default_value = "/run/rigos/state-status.json")] + status: PathBuf, + #[arg(long)] + output: PathBuf, + #[arg(long)] + flight_source: Option, + #[arg(long)] + flight_ref: Option, + }, + Discover { + #[arg(long, default_value = "/run/rigos/boot-device.json")] + attestation: PathBuf, + #[arg(long, default_value = "/run/rigos/state-status.json")] + status: PathBuf, + }, + Transact { + #[arg(long, default_value = "/var/lib/rigos")] + state: PathBuf, + #[arg(long, default_value = "/proc/cmdline")] + cmdline: PathBuf, + }, + Recover { + #[arg(long, default_value = "/var/lib/rigos")] + state: PathBuf, + }, + Gate { + #[arg(long, default_value = "/var/lib/rigos")] + state: PathBuf, + #[arg(long, default_value = "/proc/cmdline")] + cmdline: PathBuf, + }, + Timezone { + #[arg(long, default_value = "/var/lib/rigos")] + state: PathBuf, + }, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +struct Attestation { + schema: String, + boot_id: String, + verification_outcome: String, + disk: AttestedDisk, + partition1: AttestedPartition, + root: AttestedRoot, +} +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +struct AttestedDisk { + #[serde(rename = "path")] + _path: String, + major_minor: String, + ptuuid: String, +} +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +struct AttestedPartition { + path: String, + major_minor: String, + partuuid: String, +} +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +struct AttestedRoot { + major_minor: String, +} +#[derive(Deserialize)] +struct Status { + outcome: String, +} +#[derive(Deserialize)] +struct CommitRequest { + proposal: Proposal, + identity: IdentityRecord, +} +#[derive(Clone, Deserialize, Serialize)] +struct Policy { + timezone: String, + miner_start_mode: MinerStartMode, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +struct RuntimeSnapshot { + schema: String, + boot_id: String, + previous_revision: Option, + timezone: String, + miner_enabled: bool, + miner_active: bool, +} +fn main() -> ExitCode { + let cli = Cli::parse(); + match execute(cli) { + Ok(value) => { + println!("{}", serde_json::to_string(&value).unwrap()); + ExitCode::SUCCESS + } + Err(error) => { + eprintln!("{}", serde_json::to_string(&error.diagnostic).unwrap()); + ExitCode::from(2) + } + } +} + +fn execute(cli: Cli) -> Result { + match cli.command { + Commands::Prepare { + attestation, + status, + output, + flight_source, + flight_ref, + } => { + let proposal = prepare( + &attestation, + &status, + flight_source.as_deref(), + flight_ref.as_deref(), + )?; + write_private_json(&output, &proposal)?; + Ok(json!({"outcome":"prepared"})) + } + Commands::Discover { + attestation, + status, + } => with_verified_efi(&attestation, &status, discover), + Commands::Transact { state, cmdline } => { + let request = read_commit_request()?; + let (revision, miner_started) = + transact(&state, &cmdline, &request, &mut SystemRuntime)?; + Ok(json!({"outcome":"applied","revision":revision,"miner_started":miner_started})) + } + Commands::Recover { state } => { + recover_pending(&state, &mut SystemRuntime)?; + Ok(json!({"outcome":"recovered"})) + } + Commands::Gate { state, cmdline } => { + let allowed = gate(&state, &cmdline)?; + if !allowed { + std::process::exit(1); + } + Ok(json!({"outcome":"allowed"})) + } + Commands::Timezone { state } => { + let policy_path = state.join("current/policy.json"); + if !policy_path.is_file() { + return Ok(json!({"outcome":"no_configuration"})); + } + let policy: Policy = read_json(&policy_path)?; + apply_timezone(&policy.timezone)?; + Ok(json!({"outcome":"timezone_applied"})) + } + } +} + +fn read_commit_request() -> Result { + let mut input = Vec::new(); + io::stdin() + .take(2 * 1024 * 1024) + .read_to_end(&mut input) + .map_err(io_failure)?; + serde_json::from_slice(&input) + .map_err(|_| diagnostic("RIGOS_CONFIG_INVALID_VALUE", "commit request is invalid")) +} + +fn prepare( + attestation_path: &Path, + status_path: &Path, + source: Option<&str>, + reference: Option<&str>, +) -> Result { + with_verified_efi(attestation_path, status_path, |root| { + prepare_from_root(root, source, reference) + }) +} + +fn with_verified_efi( + attestation_path: &Path, + status_path: &Path, + operation: impl FnOnce(&Path) -> Result, +) -> Result { + let attestation: Attestation = read_json(attestation_path)?; + let status: Status = read_json(status_path)?; + if attestation.schema != "rigos.boot-device/v1" + || attestation.verification_outcome != "verified" + || !state_allows_config(&status.outcome) + { + return Err(diagnostic( + "RIGOS_CONFIG_BOOT_DEVICE_UNPROVEN", + "verified persistent boot USB is required", + )); + } + let fresh = revalidate_attestation(&attestation)?; + let stage = PathBuf::from(format!("/run/rigos/config-stage-{}", Uuid::new_v4())); + fs::create_dir_all(&stage).map_err(io_failure)?; + let mounted = Command::new("mount") + .args(["-o", "ro,nodev,nosuid,noexec", &fresh.partition1.path]) + .arg(&stage) + .status() + .map_err(io_failure)? + .success(); + if !mounted { + let _ = fs::remove_dir(&stage); + return Err(diagnostic( + "RIGOS_CONFIG_BOOT_DEVICE_UNPROVEN", + "exact EFI partition could not be mounted read only", + )); + } + let mounted_identity = Command::new("findmnt") + .args(["--noheadings", "--output", "MAJ:MIN", "--target"]) + .arg(&stage) + .output() + .map_err(io_failure)?; + if !mounted_identity.status.success() + || String::from_utf8_lossy(&mounted_identity.stdout).trim() != fresh.partition1.major_minor + { + let _ = Command::new("umount").arg(&stage).status(); + let _ = fs::remove_dir_all(&stage); + return Err(diagnostic( + "RIGOS_CONFIG_BOOT_DEVICE_UNPROVEN", + "mounted EFI identity changed after verification", + )); + } + let result = operation(&stage); + let _ = Command::new("umount").arg(&stage).status(); + let _ = fs::remove_dir_all(&stage); + result +} + +fn revalidate_attestation(expected: &Attestation) -> Result { + let current_boot_id = fs::read_to_string("/proc/sys/kernel/random/boot_id") + .map_err(io_failure)? + .trim() + .to_owned(); + if current_boot_id != expected.boot_id { + return Err(diagnostic( + "RIGOS_CONFIG_BOOT_DEVICE_UNPROVEN", + "boot device attestation belongs to another boot", + )); + } + let temporary = PathBuf::from(format!("/run/rigos/revalidate-{}", Uuid::new_v4())); + fs::create_dir(&temporary).map_err(io_failure)?; + let attestation_path = temporary.join("boot-device.json"); + let status_path = temporary.join("state-status.json"); + let result = Command::new("/usr/lib/rigos/rigos-state-init") + .arg("--dry-run") + .arg("--attestation-only") + .arg("--attestation") + .arg(&attestation_path) + .arg("--status") + .arg(&status_path) + .status() + .map_err(io_failure); + let fresh = match result { + Ok(status) if status.success() => read_json::(&attestation_path), + _ => Err(diagnostic( + "RIGOS_CONFIG_BOOT_DEVICE_UNPROVEN", + "boot device revalidation failed", + )), + }; + let _ = fs::remove_dir_all(&temporary); + let fresh = fresh?; + if !attestation_identity_matches(expected, &fresh) { + return Err(diagnostic( + "RIGOS_CONFIG_BOOT_DEVICE_UNPROVEN", + "boot device identity changed after attestation", + )); + } + Ok(fresh) +} + +fn attestation_identity_matches(expected: &Attestation, fresh: &Attestation) -> bool { + expected.schema == "rigos.boot-device/v1" + && fresh.schema == expected.schema + && fresh.verification_outcome == "verified" + && fresh.boot_id == expected.boot_id + && fresh.disk.major_minor == expected.disk.major_minor + && fresh + .disk + .ptuuid + .eq_ignore_ascii_case(&expected.disk.ptuuid) + && fresh.partition1.major_minor == expected.partition1.major_minor + && fresh + .partition1 + .partuuid + .eq_ignore_ascii_case(&expected.partition1.partuuid) + && fresh.root.major_minor == expected.root.major_minor +} + +fn prepare_from_root( + root: &Path, + source_override: Option<&str>, + reference_override: Option<&str>, +) -> Result { + let config_path = root.join("rigos/rig.conf"); + let metadata = fs::symlink_metadata(&config_path) + .map_err(|_| diagnostic("RIGOS_CONFIG_FILE_MISSING", "rig.conf is missing"))?; + if !metadata.file_type().is_file() || metadata.file_type().is_symlink() { + return Err(diagnostic( + "RIGOS_CONFIG_INVALID_VALUE", + "rig.conf must be a regular file", + )); + } + let config = read_bounded(&config_path, rigos_config::MAX_CONFIG_BYTES)?; + let mut profile = parse_rig_profile(&config)?; + if !Path::new("/usr/share/zoneinfo") + .join(&profile.timezone) + .is_file() + { + return Err(diagnostic( + "RIGOS_CONFIG_INVALID_VALUE", + "configured IANA timezone is not installed", + )); + } + if let Some(source) = source_override { + if profile.flight_source != FlightSource::Interactive { + return Err(diagnostic( + "RIGOS_CONFIG_INVALID_VALUE", + "flight selection override requires interactive source", + )); + } + profile.flight_source = match source { + "native" => FlightSource::Native, + "import" => FlightSource::Import, + _ => { + return Err(diagnostic( + "RIGOS_CONFIG_INVALID_VALUE", + "invalid interactive source selection", + )); + } + }; + profile.flight_ref = reference_override.map(str::to_owned); + } + let (mut sheet, provenance, sheet_bytes) = match profile.flight_source { + FlightSource::Native => { + let filename = format!("{}.json", profile.flight_ref.as_deref().unwrap()); + let path = safe_join(&root.join("rigos/flight-sheets"), &filename)?; + let bytes = read_regular_bounded(&path, rigos_config::MAX_SHEET_BYTES)?; + (parse_flight_sheet(&bytes, &filename)?, None, bytes) + } + FlightSource::Import => { + let filename = profile.flight_ref.as_deref().unwrap(); + let path = safe_join(&root.join("rigos/import"), filename)?; + let bytes = read_regular_bounded(&path, rigos_config::MAX_SHEET_BYTES)?; + let (sheet, provenance) = compatibility::import_hive_style(&bytes, filename)?; + (sheet, Some(provenance), bytes) + } + FlightSource::Interactive => { + return Err(diagnostic( + "RIGOS_FLIGHT_SHEET_MISSING", + "interactive flight selection is required", + )); + } + }; + if let Some(provenance) = &provenance { + if provenance.external_reference.is_some() { + sheet.identity_ref = "unresolved".into(); + } + } + Ok(Proposal { + schema: "rigos.config-proposal/v1".into(), + profile, + flight_sheet: sheet, + provenance, + source_sha256: hex::encode(Sha256::digest(&sheet_bytes)), + }) +} + +fn discover(root: &Path) -> Result { + fn names(path: PathBuf, native: bool) -> Result, ConfigError> { + let mut values = Vec::new(); + let mut folded = std::collections::BTreeSet::new(); + if !path.exists() { + return Ok(values); + } + for entry in fs::read_dir(path).map_err(io_failure)?.take(128) { + let entry = entry.map_err(io_failure)?; + let metadata = fs::symlink_metadata(entry.path()).map_err(io_failure)?; + let name = entry.file_name().to_string_lossy().into_owned(); + if !metadata.file_type().is_file() + || metadata.file_type().is_symlink() + || !rigos_config::safe_json_basename(&name) + { + continue; + } + let folded_name = name.to_ascii_lowercase(); + if !folded.insert(folded_name) { + return Err(diagnostic( + "RIGOS_FLIGHT_SHEET_INVALID", + "duplicate case insensitive filename", + )); + } + values.push(if native { + name.trim_end_matches(".json").to_owned() + } else { + name + }); + } + values.sort(); + Ok(values) + } + Ok( + json!({"native":names(root.join("rigos/flight-sheets"), true)?,"import":names(root.join("rigos/import"), false)?}), + ) +} + +trait RuntimeOps { + fn timezone(&mut self) -> Result; + fn set_timezone(&mut self, timezone: &str) -> Result<(), ConfigError>; + fn miner_enabled(&mut self) -> Result; + fn set_miner_enabled(&mut self, enabled: bool) -> Result<(), ConfigError>; + fn miner_active(&mut self) -> Result; + fn stop_miner(&mut self) -> Result<(), ConfigError>; + fn start_miner(&mut self) -> Result<(), ConfigError>; +} + +struct SystemRuntime; + +impl RuntimeOps for SystemRuntime { + fn timezone(&mut self) -> Result { + output("timedatectl", &["show", "--property=Timezone", "--value"]) + } + fn set_timezone(&mut self, timezone: &str) -> Result<(), ConfigError> { + run("timedatectl", &["set-timezone", timezone]) + } + fn miner_enabled(&mut self) -> Result { + let status = Command::new("systemctl") + .args(["is-enabled", "--quiet", "rigos-miner.service"]) + .status() + .map_err(io_failure)?; + match status.code() { + Some(0) => Ok(true), + Some(1) => Ok(false), + _ => Err(transaction_error( + "snapshot_enabled", + "could not read miner enabled state", + )), + } + } + fn set_miner_enabled(&mut self, enabled: bool) -> Result<(), ConfigError> { + run( + "systemctl", + &[ + if enabled { "enable" } else { "disable" }, + "rigos-miner.service", + ], + ) + } + fn miner_active(&mut self) -> Result { + let status = Command::new("systemctl") + .args(["is-active", "--quiet", "rigos-miner.service"]) + .status() + .map_err(io_failure)?; + match status.code() { + Some(0) => Ok(true), + Some(3) => Ok(false), + _ => Err(transaction_error( + "snapshot_active", + "could not read miner running state", + )), + } + } + fn stop_miner(&mut self) -> Result<(), ConfigError> { + run("systemctl", &["stop", "rigos-miner.service"]) + } + fn start_miner(&mut self) -> Result<(), ConfigError> { + run("systemctl", &["start", "rigos-miner.service"]) + } +} + +fn transact( + state: &Path, + cmdline: &Path, + request: &CommitRequest, + runtime: &mut impl RuntimeOps, +) -> Result<(String, bool), ConfigError> { + let pending = state.join(".pending-transaction.json"); + if pending.exists() { + return Err(transaction_error( + "snapshot", + "an incomplete configuration transaction already exists", + )); + } + let snapshot = RuntimeSnapshot { + schema: "rigos.runtime-snapshot/v1".into(), + boot_id: fs::read_to_string("/proc/sys/kernel/random/boot_id") + .map_err(io_failure)? + .trim() + .to_owned(), + previous_revision: fs::read_link(state.join("current")).ok(), + timezone: runtime.timezone()?, + miner_enabled: runtime.miner_enabled()?, + miner_active: runtime.miner_active()?, + }; + write_private_json(&pending, &snapshot)?; + let operation = (|| { + runtime.stop_miner().map_err(|_| { + transaction_error( + "stop_before_commit", + "miner could not be stopped before commit", + ) + })?; + if runtime.miner_active()? { + return Err(transaction_error( + "stop_before_commit", + "miner remained active before commit", + )); + } + let revision = + commit_revision(state, &request.proposal, &request.identity).map_err(|_| { + transaction_error("pointer_swap", "configuration revision commit failed") + })?; + let started = apply_new_runtime(&request.proposal.profile, cmdline, runtime)?; + Ok((revision, started)) + })(); + match operation { + Ok(result) => { + fs::remove_file(&pending).map_err(io_failure)?; + Ok(result) + } + Err(failure) => { + let rollback = rollback_transaction(state, &snapshot, runtime); + if rollback.is_ok() { + let _ = fs::remove_file(&pending); + } + match rollback { + Ok(()) => Err(failure), + Err(rollback_failure) => Err(transaction_error( + failure.diagnostic.key.as_deref().unwrap_or("unknown"), + format!( + "{}; rollback failed at {}", + failure.diagnostic.message, + rollback_failure + .diagnostic + .key + .as_deref() + .unwrap_or("unknown") + ), + )), + } + } + } +} + +fn apply_new_runtime( + profile: &rigos_config::RigProfile, + cmdline: &Path, + runtime: &mut impl RuntimeOps, +) -> Result { + runtime + .set_timezone(&profile.timezone) + .map_err(|_| transaction_error("timezone", "timezone apply failed"))?; + let enabled = profile.miner_start_mode == MinerStartMode::OnBoot; + runtime + .set_miner_enabled(enabled) + .map_err(|_| transaction_error("enabled_state", "miner enabled state apply failed"))?; + let started = enabled && !cmdline_blocks_mining(cmdline); + if started { + runtime + .start_miner() + .map_err(|_| transaction_error("miner_start", "miner start failed"))?; + if !runtime.miner_active()? { + return Err(transaction_error( + "miner_start", + "miner did not become active", + )); + } + } + Ok(started) +} + +fn state_allows_config(outcome: &str) -> bool { + matches!(outcome, "ready" | "grown") +} + +fn rollback_transaction( + state: &Path, + snapshot: &RuntimeSnapshot, + runtime: &mut impl RuntimeOps, +) -> Result<(), ConfigError> { + let mut first_error = None; + if runtime.stop_miner().is_err() { + first_error = Some(transaction_error( + "rollback_stop", + "miner could not be stopped", + )); + } + if first_error.is_none() + && restore_pointer(state, snapshot.previous_revision.as_deref()).is_err() + { + first_error = Some(transaction_error( + "rollback_pointer", + "previous revision could not be restored", + )); + } + let same_boot = fs::read_to_string("/proc/sys/kernel/random/boot_id") + .map(|value| value.trim() == snapshot.boot_id) + .unwrap_or(false); + if first_error.is_none() { + if let Err(error) = restore_runtime(snapshot, same_boot, runtime) { + first_error = Some(error); + } + } + if let Some(error) = first_error { + let _ = runtime.stop_miner(); + return Err(error); + } + Ok(()) +} + +fn restore_runtime( + snapshot: &RuntimeSnapshot, + restore_running: bool, + runtime: &mut impl RuntimeOps, +) -> Result<(), ConfigError> { + runtime.set_timezone(&snapshot.timezone).map_err(|_| { + transaction_error( + "rollback_timezone", + "previous timezone could not be restored", + ) + })?; + runtime + .set_miner_enabled(snapshot.miner_enabled) + .map_err(|_| { + transaction_error( + "rollback_enabled_state", + "previous enabled state could not be restored", + ) + })?; + if restore_running && snapshot.miner_active { + runtime.start_miner().map_err(|_| { + transaction_error( + "rollback_running_state", + "previous running state could not be restored", + ) + })?; + } + Ok(()) +} + +fn recover_pending(state: &Path, runtime: &mut impl RuntimeOps) -> Result<(), ConfigError> { + let pending = state.join(".pending-transaction.json"); + if !pending.is_file() { + return Ok(()); + } + let snapshot: RuntimeSnapshot = read_json(&pending)?; + rollback_transaction(state, &snapshot, runtime)?; + fs::remove_file(pending).map_err(io_failure) +} + +fn restore_pointer(state: &Path, previous: Option<&Path>) -> Result<(), ConfigError> { + if let Some(previous) = previous { + let temporary = state.join(format!(".rollback-{}", Uuid::new_v4())); + create_symlink(previous, &temporary)?; + fs::rename(temporary, state.join("current")).map_err(io_failure)?; + } else { + for name in [ + "current", + "policy.json", + "xmrig.json", + "flight-sheets", + "identities", + "external-identity-map.json", + ] { + let _ = fs::remove_file(state.join(name)); + } + } + Ok(()) +} + +fn gate(state: &Path, cmdline: &Path) -> Result { + let policy: Policy = read_json(&state.join("current/policy.json"))?; + let blocked = cmdline_blocks_mining(cmdline); + Ok(policy.miner_start_mode == MinerStartMode::OnBoot + && !blocked + && state.join("current/xmrig.json").is_file()) +} + +fn cmdline_blocks_mining(cmdline: &Path) -> bool { + fs::read_to_string(cmdline) + .unwrap_or_default() + .split_whitespace() + .any(|item| item == "rigos.nomine=1" || item == "rigos.stateless=1") +} + +fn apply_timezone(timezone: &str) -> Result<(), ConfigError> { + let zone = Path::new("/usr/share/zoneinfo").join(timezone); + if !zone.is_file() { + return Err(diagnostic( + "RIGOS_CONFIG_INVALID_VALUE", + "configured timezone is not installed", + )); + } + run("timedatectl", &["set-timezone", timezone]) +} + +fn output(program: &str, arguments: &[&str]) -> Result { + let result = Command::new(program) + .args(arguments) + .output() + .map_err(io_failure)?; + if !result.status.success() { + return Err(transaction_error( + "runtime_query", + format!("runtime query {program} failed"), + )); + } + Ok(String::from_utf8_lossy(&result.stdout).trim().to_owned()) +} + +fn transaction_error(stage: &str, message: impl Into) -> ConfigError { + ConfigError { + diagnostic: ConfigDiagnostic { + code: "RIGOS_CONFIG_APPLY_FAILED".into(), + file: None, + line: None, + key: Some(stage.into()), + message: message.into(), + }, + } +} + +fn read_regular_bounded(path: &Path, maximum: usize) -> Result, ConfigError> { + let metadata = fs::symlink_metadata(path).map_err(|_| { + diagnostic( + "RIGOS_FLIGHT_SHEET_MISSING", + "selected flight sheet is missing", + ) + })?; + if !metadata.file_type().is_file() + || metadata.file_type().is_symlink() + || metadata.len() > maximum as u64 + { + return Err(diagnostic( + "RIGOS_FLIGHT_SHEET_INVALID", + "selected flight sheet is not a bounded regular file", + )); + } + read_bounded(path, maximum) +} +fn read_bounded(path: &Path, maximum: usize) -> Result, ConfigError> { + let bytes = fs::read(path).map_err(io_failure)?; + if bytes.len() > maximum { + return Err(diagnostic( + "RIGOS_CONFIG_FILE_TOO_LARGE", + "input exceeds its size limit", + )); + } + Ok(bytes) +} +fn read_json Deserialize<'de>>(path: &Path) -> Result { + let bytes = read_bounded(path, 2 * 1024 * 1024)?; + serde_json::from_slice(&bytes).map_err(|_| { + diagnostic( + "RIGOS_CONFIG_INVALID_VALUE", + "required JSON state is invalid", + ) + }) +} +fn write_private_json(path: &Path, value: &T) -> Result<(), ConfigError> { + let mut options = fs::OpenOptions::new(); + options.create_new(true).write(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let file = options.open(path).map_err(io_failure)?; + serde_json::to_writer_pretty(file, value) + .map_err(|_| diagnostic("RIGOS_CONFIG_INVALID_VALUE", "proposal write failed")) +} +fn run(program: &str, arguments: &[&str]) -> Result<(), ConfigError> { + if Command::new(program) + .args(arguments) + .status() + .map_err(io_failure)? + .success() + { + Ok(()) + } else { + Err(diagnostic( + "RIGOS_CONFIG_INVALID_VALUE", + format!("runtime apply stage {program} failed"), + )) + } +} +fn diagnostic(code: &str, message: impl Into) -> ConfigError { + ConfigError { + diagnostic: ConfigDiagnostic { + code: code.into(), + file: None, + line: None, + key: None, + message: message.into(), + }, + } +} +fn io_failure(value: io::Error) -> ConfigError { + diagnostic( + "RIGOS_CONFIG_INVALID_VALUE", + format!("I/O operation failed: {value}"), + ) +} +#[cfg(unix)] +fn create_symlink(target: &Path, link: &Path) -> Result<(), ConfigError> { + std::os::unix::fs::symlink(target, link).map_err(io_failure) +} +#[cfg(not(unix))] +fn create_symlink(_target: &Path, _link: &Path) -> Result<(), ConfigError> { + Err(diagnostic( + "RIGOS_CONFIG_INVALID_VALUE", + "rollback requires Unix symlinks", + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn attestation() -> Attestation { + Attestation { + schema: "rigos.boot-device/v1".into(), + boot_id: "boot-a".into(), + verification_outcome: "verified".into(), + disk: AttestedDisk { + _path: "/dev/sda".into(), + major_minor: "8:0".into(), + ptuuid: "5249474f".into(), + }, + partition1: AttestedPartition { + path: "/dev/sda1".into(), + major_minor: "8:1".into(), + partuuid: "5249474f-01".into(), + }, + root: AttestedRoot { + major_minor: "8:2".into(), + }, + } + } + + #[test] + fn attestation_uses_stable_identity_not_device_path() { + let expected = attestation(); + let mut renamed = expected.clone(); + renamed.disk._path = "/dev/sdz".into(); + renamed.partition1.path = "/dev/sdz1".into(); + assert!(attestation_identity_matches(&expected, &renamed)); + for change in ["boot", "disk", "ptuuid", "partition", "partuuid", "root"] { + let mut swapped = expected.clone(); + match change { + "boot" => swapped.boot_id = "boot-b".into(), + "disk" => swapped.disk.major_minor = "9:0".into(), + "ptuuid" => swapped.disk.ptuuid = "different".into(), + "partition" => swapped.partition1.major_minor = "9:1".into(), + "partuuid" => swapped.partition1.partuuid = "different-01".into(), + "root" => swapped.root.major_minor = "9:2".into(), + _ => unreachable!(), + } + assert!(!attestation_identity_matches(&expected, &swapped)); + } + } + + #[test] + fn limited_capacity_is_a_negative_gate() { + assert!(state_allows_config("ready")); + assert!(state_allows_config("grown")); + assert!(!state_allows_config("limited_capacity")); + assert!(!state_allows_config("blocked_layout_mismatch")); + } + + #[derive(Default)] + struct FakeRuntime { + timezone: String, + enabled: bool, + active: bool, + fail_once: Option<&'static str>, + events: Vec, + } + + impl FakeRuntime { + fn fail(&mut self, stage: &'static str) -> Result<(), ConfigError> { + self.events.push(stage.into()); + if self.fail_once == Some(stage) { + self.fail_once = None; + Err(transaction_error(stage, "injected failure")) + } else { + Ok(()) + } + } + } + + impl RuntimeOps for FakeRuntime { + fn timezone(&mut self) -> Result { + Ok(self.timezone.clone()) + } + fn set_timezone(&mut self, timezone: &str) -> Result<(), ConfigError> { + self.fail("timezone")?; + self.timezone = timezone.into(); + Ok(()) + } + fn miner_enabled(&mut self) -> Result { + Ok(self.enabled) + } + fn set_miner_enabled(&mut self, enabled: bool) -> Result<(), ConfigError> { + self.fail("enabled_state")?; + self.enabled = enabled; + Ok(()) + } + fn miner_active(&mut self) -> Result { + Ok(self.active) + } + fn stop_miner(&mut self) -> Result<(), ConfigError> { + self.fail("stop")?; + self.active = false; + Ok(()) + } + fn start_miner(&mut self) -> Result<(), ConfigError> { + self.fail("miner_start")?; + self.active = true; + Ok(()) + } + } + + #[test] + fn runtime_failure_stages_restore_previous_side_effects() { + let profile = rigos_config::RigProfile { + node_name: "rig01".into(), + timezone: "Asia/Bangkok".into(), + flight_source: FlightSource::Interactive, + flight_ref: None, + miner_start_mode: MinerStartMode::OnBoot, + }; + let snapshot = RuntimeSnapshot { + schema: "rigos.runtime-snapshot/v1".into(), + boot_id: "same".into(), + previous_revision: None, + timezone: "UTC".into(), + miner_enabled: false, + miner_active: true, + }; + for stage in ["timezone", "enabled_state", "miner_start"] { + let mut runtime = FakeRuntime { + timezone: "UTC".into(), + enabled: false, + active: false, + fail_once: Some(stage), + events: vec![], + }; + let error = apply_new_runtime(&profile, Path::new("missing-cmdline"), &mut runtime) + .unwrap_err(); + assert_eq!(error.diagnostic.key.as_deref(), Some(stage)); + restore_runtime(&snapshot, true, &mut runtime).unwrap(); + assert_eq!(runtime.timezone, "UTC"); + assert!(!runtime.enabled); + assert!(runtime.active); + } + } + + #[test] + fn rollback_failure_forces_miner_stopped() { + let snapshot = RuntimeSnapshot { + schema: "rigos.runtime-snapshot/v1".into(), + boot_id: "same".into(), + previous_revision: None, + timezone: "UTC".into(), + miner_enabled: true, + miner_active: true, + }; + let mut runtime = FakeRuntime { + timezone: "Asia/Bangkok".into(), + enabled: true, + active: true, + fail_once: Some("timezone"), + events: vec![], + }; + assert!(restore_runtime(&snapshot, true, &mut runtime).is_err()); + runtime.stop_miner().unwrap(); + assert!(!runtime.active); + } +} diff --git a/crates/rigos-config/tests/firstboot_tty.rs b/crates/rigos-config/tests/firstboot_tty.rs new file mode 100644 index 00000000..d3cab77b --- /dev/null +++ b/crates/rigos-config/tests/firstboot_tty.rs @@ -0,0 +1,29 @@ +use std::fs; +use std::path::PathBuf; + +fn firstboot_unit() -> String { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../build/usb/includes.chroot/etc/systemd/system/rigos-firstboot.service"); + fs::read_to_string(&path) + .unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display())) +} + +#[test] +fn firstboot_releases_tty1_to_getty_after_exit() { + let unit = firstboot_unit(); + + assert!( + unit.lines().any(|line| line == "Before=getty@tty1.service"), + "firstboot must finish before tty1 getty starts" + ); + assert!( + !unit + .lines() + .any(|line| line == "Conflicts=getty@tty1.service"), + "firstboot must not permanently block tty1 getty" + ); + assert!( + !unit.lines().any(|line| line == "RemainAfterExit=yes"), + "firstboot must release tty1 after the process exits" + ); +} diff --git a/crates/rigos-config/tests/fixtures/hive-xmrig.json b/crates/rigos-config/tests/fixtures/hive-xmrig.json new file mode 100644 index 00000000..125048e3 --- /dev/null +++ b/crates/rigos-config/tests/fixtures/hive-xmrig.json @@ -0,0 +1,28 @@ +{ + "schema": "synthetic.hive-flight-sheet/v1", + "items": [ + { + "name": "Synthetic XMR SSL", + "coin": "XMR", + "miner": "xmrig-new", + "pool_urls": [ + "stratum+ssl://pool-one.invalid:443", + "pool-two.invalid:10001" + ], + "pool_ssl": [true, false], + "wal_id": "fixture-wallet-ref", + "miner_config": { + "cpu": 1, + "fork": "xmrig", + "hugepages": 1280, + "algo": "rx/0", + "url": "%URL%", + "template": "%WAL%", + "pass": "%WORKER_NAME%", + "cpu_config": "\"huge-pages\": true,\n\"max-threads-hint\": 75", + "user_config": "\"cpu\": {\"huge-pages\": true}\n\"api\": {\"worker-id\": \"fixture-worker\"}" + }, + "autostart": true + } + ] +} diff --git a/crates/rigos-config/tests/import_fixture.rs b/crates/rigos-config/tests/import_fixture.rs new file mode 100644 index 00000000..8483f3fd --- /dev/null +++ b/crates/rigos-config/tests/import_fixture.rs @@ -0,0 +1,126 @@ +#[path = "../src/lib_entry.rs"] +mod compatibility; + +use compatibility::import_hive_style; +use serde_json::{Value, json}; + +#[test] +fn synthetic_hive_fixture_maps_without_runtime_identity() { + let input = include_bytes!("fixtures/hive-xmrig.json"); + let (sheet, provenance) = import_hive_style(input, "hive-xmrig.json").unwrap(); + assert_eq!(sheet.backend, "xmrig"); + assert_eq!(sheet.algorithm, "rx/0"); + assert_eq!(sheet.worker_template, "{node_name}"); + assert_eq!(sheet.pools.len(), 2); + assert!(sheet.pools[0].tls); + assert_eq!(sheet.cpu.max_threads_hint, 75); + assert!(sheet.cpu.huge_pages); + assert_eq!(sheet.identity_ref, "hive-wal-fixture-wallet-ref"); + assert_eq!(provenance.warnings.len(), 1); + assert!( + !serde_json::to_string(&sheet) + .unwrap() + .contains("RIG_PASSWD") + ); +} + +#[test] +fn hive_envelope_rejects_ambiguous_or_invalid_pool_contracts() { + let cases = [ + r#"{"items":[]}"#, + r#"{"items":[{},{}]}"#, + r#"{"items":[{"miner":"xmrig","pool_urls":["pool.invalid:1",7],"pool_ssl":[false,false],"miner_config":{"algo":"rx/0","url":"%URL%","template":"%WAL%","pass":"%WORKER_NAME%"}}]}"#, + r#"{"items":[{"miner":"xmrig","pool_urls":["pool.invalid:1","backup.invalid:2"],"pool_ssl":[false],"miner_config":{"algo":"rx/0","url":"%URL%","template":"%WAL%","pass":"%WORKER_NAME%"}}]}"#, + r#"{"items":[{"miner":"xmrig","pool_urls":["pool.invalid:1"],"pool_ssl":["false"],"miner_config":{"algo":"rx/0","url":"%URL%","template":"%WAL%","pass":"%WORKER_NAME%"}}]}"#, + r#"{"items":[{"miner":"xmrig","pool_urls":["stratum+ssl://pool.invalid:1"],"pool_ssl":false,"miner_config":{"algo":"rx/0","url":"%URL%","template":"%WAL%","pass":"%WORKER_NAME%"}}]}"#, + r#"{"items":[{"miner":"xmrig","pool_urls":["pool.invalid:1"],"pool_ssl":false,"miner_config":{"algo":"rx/0","url":"pool.invalid:1","template":"%WAL%","pass":"%WORKER_NAME%"}}]}"#, + r#"{"items":[{"miner":"xmrig","pool_urls":["pool.invalid:1"],"pool_ssl":false,"miner_config":{"algo":"rx/0","url":"%URL%","template":"%WAL%","pass":"%WORKER_NAME%","dangerous":"value"}}]}"#, + ]; + for input in cases { + assert!(import_hive_style(input.as_bytes(), "hive-xmrig.json").is_err()); + } +} + +fn real_shape(cpu: Value, fork: Value, hugepages: Value, huge_pages: bool) -> Vec { + serde_json::to_vec(&json!({ + "items": [{ + "name": "Synthetic XMR", + "coin": "XMR", + "miner": "xmrig-new", + "pool_urls": ["pool.invalid:10001"], + "pool_ssl": false, + "wal_id": "fixture-wallet-ref", + "miner_config": { + "cpu": cpu, + "fork": fork, + "hugepages": hugepages, + "algo": "rx/0", + "url": "%URL%", + "template": "%WAL%", + "pass": "%WORKER_NAME%", + "cpu_config": format!("\"huge-pages\": {huge_pages}"), + "user_config": format!("\"cpu\": {{\"huge-pages\": {huge_pages}}}\n\"api\": {{\"worker-id\": \"fixture\"}}") + } + }] + })) + .unwrap() +} + +#[test] +fn cpu_and_fork_markers_are_strict() { + assert!( + import_hive_style( + &real_shape(json!(1), json!("xmrig"), json!(1280), true), + "fixture.json" + ) + .is_ok() + ); + assert!( + import_hive_style( + &real_shape(json!(true), json!("xmrig"), json!(1280), true), + "fixture.json" + ) + .is_ok() + ); + for cpu in [json!(0), json!(false), json!(2), json!("1")] { + assert!( + import_hive_style( + &real_shape(cpu, json!("xmrig"), json!(1280), true), + "fixture.json" + ) + .is_err() + ); + } + assert!( + import_hive_style( + &real_shape(json!(1), json!("other"), json!(1280), true), + "fixture.json" + ) + .is_err() + ); +} + +#[test] +fn hugepages_maps_and_conflicts_fail() { + let (disabled, _) = import_hive_style( + &real_shape(json!(1), json!("xmrig"), json!(0), false), + "fixture.json", + ) + .unwrap(); + assert!(!disabled.cpu.huge_pages); + assert!( + import_hive_style( + &real_shape(json!(1), json!("xmrig"), json!(1280), false), + "fixture.json" + ) + .is_err() + ); +} + +#[test] +fn unknown_nested_field_remains_rejected() { + let mut value: Value = + serde_json::from_slice(&real_shape(json!(1), json!("xmrig"), json!(1280), true)).unwrap(); + value["items"][0]["miner_config"]["unknown"] = json!(true); + assert!(import_hive_style(&serde_json::to_vec(&value).unwrap(), "fixture.json").is_err()); +} diff --git a/crates/rigos-schema/Cargo.toml b/crates/rigos-schema/Cargo.toml index 30c828cb..04c7a6dc 100644 --- a/crates/rigos-schema/Cargo.toml +++ b/crates/rigos-schema/Cargo.toml @@ -10,6 +10,7 @@ rigos-core = { path = "../rigos-core" } rigos-machine = { path = "../rigos-machine" } rigos-xmrig = { path = "../rigos-xmrig" } rigos-pool = { path = "../rigos-pool" } +rigos-config = { path = "../rigos-config" } schemars.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/rigos-schema/src/lib.rs b/crates/rigos-schema/src/lib.rs index cbdbd022..18444fa8 100644 --- a/crates/rigos-schema/src/lib.rs +++ b/crates/rigos-schema/src/lib.rs @@ -1,5 +1,6 @@ #![forbid(unsafe_code)] +use rigos_config::{FlightSheet, IdentityRecord, RigProfile}; use rigos_core::{CliEnvelope, Diagnostic}; use rigos_machine::MachineSnapshotV1; use rigos_pool::PoolProfile; @@ -312,6 +313,18 @@ pub fn schemas() -> BTreeMap<&'static str, serde_json::Value> { "state-layout-v1.schema.json", serde_json::to_value(schema_for!(StateLayoutV1)).unwrap(), ); + output.insert( + "rig-profile-v1.schema.json", + serde_json::to_value(schema_for!(RigProfile)).unwrap(), + ); + output.insert( + "flight-sheet-v1.schema.json", + serde_json::to_value(schema_for!(FlightSheet)).unwrap(), + ); + output.insert( + "identity-v1.schema.json", + serde_json::to_value(schema_for!(IdentityRecord)).unwrap(), + ); output } diff --git a/crates/rigos-state/src/lib.rs b/crates/rigos-state/src/lib.rs index 178055f4..5eb406b0 100644 --- a/crates/rigos-state/src/lib.rs +++ b/crates/rigos-state/src/lib.rs @@ -60,7 +60,13 @@ pub struct SfdiskPartition { #[derive(Debug, Clone, PartialEq, Eq)] pub struct VerifiedLayout { pub disk_path: String, + pub disk_major_minor: String, + pub disk_ptuuid: String, pub disk_size_bytes: u64, + pub efi_path: String, + pub efi_major_minor: String, + pub efi_partuuid: String, + pub root_major_minor: String, pub state_path: String, pub state_start_lba: u64, pub state_size_lba: u64, @@ -101,11 +107,50 @@ pub enum LayoutError { UnexpectedWritableMount, } +pub fn boot_parent_disk<'a>( + observed: &'a LsblkDocument, + boot_major_minor: &str, +) -> Option<&'a BlockDevice> { + observed.blockdevices.iter().find(|device| { + device.device_type == "disk" + && device + .children + .iter() + .any(|child| child.major_minor == boot_major_minor) + }) +} + pub fn validate_layout( manifest: &ImageLayoutV1, observed: &LsblkDocument, sfdisk: &SfdiskDocument, boot_major_minor: &str, +) -> Result { + validate_layout_with_state_mount(manifest, observed, sfdisk, boot_major_minor, None) +} + +pub fn validate_layout_for_attestation( + manifest: &ImageLayoutV1, + observed: &LsblkDocument, + sfdisk: &SfdiskDocument, + boot_major_minor: &str, + verified_state_mountpoint: &str, +) -> Result { + validate_layout_with_state_mount( + manifest, + observed, + sfdisk, + boot_major_minor, + Some(verified_state_mountpoint), + ) +} + +fn validate_layout_with_state_mount( + manifest: &ImageLayoutV1, + observed: &LsblkDocument, + sfdisk: &SfdiskDocument, + boot_major_minor: &str, + verified_state_mountpoint: Option<&str>, ) -> Result { if manifest.schema != "rigos.image-layout/v2" || manifest.partition_table != "mbr" { return Err(LayoutError::PartitionTableMismatch); @@ -116,17 +161,8 @@ pub fn validate_layout( return Err(LayoutError::PartitionTableMismatch); } - let disk = observed - .blockdevices - .iter() - .find(|device| { - device.device_type == "disk" - && device - .children - .iter() - .any(|child| child.major_minor == boot_major_minor) - }) - .ok_or(LayoutError::AmbiguousBootDevice)?; + let disk = + boot_parent_disk(observed, boot_major_minor).ok_or(LayoutError::AmbiguousBootDevice)?; if disk.tran.as_deref() != Some("usb") || disk.ro { return Err(LayoutError::NotWritableUsb); @@ -211,6 +247,11 @@ pub fn validate_layout( .iter() .find(|child| child.partn == Some(manifest.final_state_partition)) .ok_or(LayoutError::StateNotFinal)?; + let efi = disk + .children + .iter() + .find(|child| child.partn == Some(1)) + .ok_or(LayoutError::PartitionSetMismatch)?; let max_start = disk.children.iter().filter_map(|child| child.start).max(); let state_label_matches = matches!( state.label.as_deref(), @@ -220,19 +261,29 @@ pub fn validate_layout( return Err(LayoutError::StateNotFinal); } if disk.children.iter().any(|child| { - child.major_minor != boot_major_minor - && child - .mountpoints - .iter() - .flatten() - .any(|mount| !mount.is_empty()) + if child.major_minor == boot_major_minor { + return false; + } + child.mountpoints.iter().flatten().any(|mount| { + if mount.is_empty() { + return false; + } + !(child.partn == Some(manifest.final_state_partition) + && verified_state_mountpoint == Some(mount.as_str())) + }) }) { return Err(LayoutError::UnexpectedWritableMount); } Ok(VerifiedLayout { disk_path: disk.path.clone(), + disk_major_minor: disk.major_minor.clone(), + disk_ptuuid: disk.ptuuid.clone().unwrap_or_default(), disk_size_bytes: disk.size, + efi_path: efi.path.clone(), + efi_major_minor: efi.major_minor.clone(), + efi_partuuid: efi.partuuid.clone().unwrap_or_default(), + root_major_minor: boot_major_minor.to_owned(), state_path: state.path.clone(), state_start_lba: state.start.unwrap_or_default(), state_size_lba: state.size / u64::from(manifest.logical_sector_size), @@ -450,6 +501,44 @@ mod tests { ); } + #[test] + fn attestation_allows_only_the_exact_verified_state_mount() { + let mut devices = observed(); + let state_index = devices.blockdevices[0] + .children + .iter() + .position(|child| child.partn == Some(4)) + .unwrap(); + devices.blockdevices[0].children[state_index].mountpoints = + vec![Some("/var/lib/rigos".into())]; + assert_eq!( + validate_layout(&manifest(), &devices, &sfdisk(), "8:2"), + Err(LayoutError::UnexpectedWritableMount) + ); + assert!( + validate_layout_for_attestation( + &manifest(), + &devices, + &sfdisk(), + "8:2", + "/var/lib/rigos" + ) + .is_ok() + ); + devices.blockdevices[0].children[state_index].mountpoints = + vec![Some("/mnt/unexpected".into())]; + assert_eq!( + validate_layout_for_attestation( + &manifest(), + &devices, + &sfdisk(), + "8:2", + "/var/lib/rigos" + ), + Err(LayoutError::UnexpectedWritableMount) + ); + } + #[test] fn read_only_media_fails() { let mut devices = observed(); diff --git a/crates/rigos-state/src/main.rs b/crates/rigos-state/src/main.rs index acda2e30..f0e92b65 100644 --- a/crates/rigos-state/src/main.rs +++ b/crates/rigos-state/src/main.rs @@ -5,7 +5,8 @@ use clap::Parser; use fs2::FileExt; use rigos_schema::{ImageLayoutV1, STATE_LAYOUT_SCHEMA, StateLayoutV1}; use rigos_state::{ - LayoutError, LsblkDocument, SfdiskDocument, StateOutcome, VerifiedLayout, validate_layout, + LayoutError, LsblkDocument, SfdiskDocument, StateOutcome, VerifiedLayout, boot_parent_disk, + validate_layout, validate_layout_for_attestation, }; use serde::Deserialize; use serde_json::json; @@ -29,8 +30,12 @@ struct Args { mountpoint: PathBuf, #[arg(long, default_value = "/run/rigos/state-status.json")] status: PathBuf, + #[arg(long, default_value = "/run/rigos/boot-device.json")] + attestation: PathBuf, #[arg(long)] dry_run: bool, + #[arg(long)] + attestation_only: bool, } #[derive(Deserialize)] @@ -46,6 +51,7 @@ struct FindmntEntry { fn main() -> ExitCode { let args = Args::parse(); + let _ = fs::remove_file(&args.attestation); let (outcome, message) = match execute(&args) { Ok(outcome) => (outcome, None), Err( @@ -114,20 +120,21 @@ fn execute(args: &Args) -> Result { .clone(); let observed = read_lsblk()?; - let boot_disk_path = observed - .blockdevices - .iter() - .find(|device| { - device.device_type == "disk" - && device - .children - .iter() - .any(|child| child.major_minor == boot_major_minor) - }) + let boot_disk_path = boot_parent_disk(&observed, &boot_major_minor) .map(|device| device.path.clone()) .ok_or_else(|| InitError::Discovery("boot parent disk was not found".into()))?; let table = read_sfdisk(&boot_disk_path)?; - let verified = validate_layout(&manifest, &observed, &table, &boot_major_minor)?; + let verified = if args.attestation_only { + validate_layout_for_attestation( + &manifest, + &observed, + &table, + &boot_major_minor, + path_str(&args.mountpoint)?, + )? + } else { + validate_layout(&manifest, &observed, &table, &boot_major_minor)? + }; let udev = String::from_utf8_lossy(&run( "udevadm", @@ -139,6 +146,30 @@ fn execute(args: &Args) -> Result { if !udev.lines().any(|line| line == "ID_BUS=usb") { return Err(LayoutError::NotWritableUsb.into()); } + write_atomic( + &args.attestation, + &json!({ + "schema":"rigos.boot-device/v1", + "boot_id":fs::read_to_string("/proc/sys/kernel/random/boot_id")?.trim(), + "verification_outcome":"verified", + "disk":{ + "path":verified.disk_path, + "major_minor":verified.disk_major_minor, + "ptuuid":verified.disk_ptuuid, + }, + "partition1":{ + "path":verified.efi_path, + "major_minor":verified.efi_major_minor, + "partuuid":verified.efi_partuuid, + }, + "root":{"major_minor":verified.root_major_minor}, + }), + )?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&args.attestation, fs::Permissions::from_mode(0o600))?; + } if args.dry_run { return Ok(StateOutcome::Ready); } @@ -247,11 +278,13 @@ fn execute(args: &Args) -> Result { fn read_lsblk() -> Result { let raw = run( - "lsblk", + "/usr/bin/python3", &[ + "/usr/lib/rigos/lsblk-compat", "--json", "--bytes", "--paths", + "--tree", "--output", "MAJ:MIN,PATH,TYPE,SIZE,RO,TRAN,PARTN,PARTTYPE,PARTUUID,PARTLABEL,START,PTTYPE,PTUUID,MOUNTPOINTS,FSTYPE,LABEL", ], diff --git a/crates/rigos-state/tests/fixtures/lsblk-tree.json b/crates/rigos-state/tests/fixtures/lsblk-tree.json new file mode 100644 index 00000000..38c8881f --- /dev/null +++ b/crates/rigos-state/tests/fixtures/lsblk-tree.json @@ -0,0 +1,96 @@ +{ + "blockdevices": [ + { + "maj:min": "8:16", + "path": "/dev/sdb", + "type": "disk", + "size": 30752636928, + "ro": false, + "tran": "usb", + "partn": null, + "parttype": null, + "partuuid": null, + "partlabel": null, + "start": null, + "pttype": "dos", + "ptuuid": "5249474f", + "mountpoints": [null], + "fstype": null, + "label": null, + "children": [ + { + "maj:min": "8:17", + "path": "/dev/sdb1", + "type": "part", + "size": 268435456, + "ro": false, + "tran": null, + "partn": 1, + "parttype": "0xc", + "partuuid": "5249474f-01", + "partlabel": null, + "start": 2048, + "pttype": null, + "ptuuid": null, + "mountpoints": [null], + "fstype": "vfat", + "label": "EFI_SYSTEM" + }, + { + "maj:min": "8:18", + "path": "/dev/sdb2", + "type": "part", + "size": 1073741824, + "ro": false, + "tran": null, + "partn": 2, + "parttype": "0x83", + "partuuid": "5249474f-02", + "partlabel": null, + "start": 526336, + "pttype": null, + "ptuuid": null, + "mountpoints": ["/run/live/medium"], + "fstype": "ext4", + "label": "RIGOS_ROOT_A" + }, + { + "maj:min": "8:19", + "path": "/dev/sdb3", + "type": "part", + "size": 1073741824, + "ro": false, + "tran": null, + "partn": 3, + "parttype": "0x83", + "partuuid": "5249474f-03", + "partlabel": null, + "start": 2623488, + "pttype": null, + "ptuuid": null, + "mountpoints": [null], + "fstype": "ext4", + "label": "RIGOS_ROOT_B" + }, + { + "maj:min": "8:20", + "path": "/dev/sdb4", + "type": "part", + "size": 268435456, + "ro": false, + "tran": null, + "partn": 4, + "parttype": "0x83", + "partuuid": "5249474f-04", + "partlabel": null, + "start": 4720640, + "pttype": null, + "ptuuid": null, + "mountpoints": [null], + "fstype": "ext4", + "label": "RIGOS_STATE_SEED" + } + ] + } + ] +} diff --git a/crates/rigos-state/tests/lsblk_compat.rs b/crates/rigos-state/tests/lsblk_compat.rs new file mode 100644 index 00000000..f45dbdad --- /dev/null +++ b/crates/rigos-state/tests/lsblk_compat.rs @@ -0,0 +1,59 @@ +use std::fs; +use std::path::PathBuf; + +use rigos_state::{LsblkDocument, boot_parent_disk}; + +fn repo_path(relative: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .join(relative) +} + +#[test] +fn state_service_wires_debian_lsblk_compatibility() { + let service_path = + repo_path("build/usb/includes.chroot/etc/systemd/system/rigos-state.service"); + let wrapper_path = repo_path("build/usb/includes.chroot/usr/lib/rigos/lsblk-compat"); + let state_source_path = repo_path("crates/rigos-state/src/main.rs"); + let service = fs::read_to_string(&service_path) + .unwrap_or_else(|error| panic!("failed to read {}: {error}", service_path.display())); + let wrapper = fs::read_to_string(&wrapper_path) + .unwrap_or_else(|error| panic!("failed to read {}: {error}", wrapper_path.display())); + let state_source = fs::read_to_string(&state_source_path) + .unwrap_or_else(|error| panic!("failed to read {}: {error}", state_source_path.display())); + + assert!(service.contains("RuntimeDirectory=rigos")); + assert!(service.contains("RuntimeDirectoryMode=0755")); + assert!(!service.contains("ExecStartPre=")); + assert!(!service.contains("Environment=PATH=")); + assert!(!service.contains("/run/rigos/compat-bin/lsblk")); + assert!(wrapper.contains("/usr/bin/lsblk")); + assert!(wrapper.contains("sysfs_root / major_minor / \"partition\"")); + assert!(wrapper.contains("device[\"partn\"] = partition_number")); + assert!(!wrapper.contains("startswith(\"/dev/sd\"")); + assert!(state_source.contains("run(\n \"/usr/bin/python3\"")); + assert!(state_source.contains("\"/usr/lib/rigos/lsblk-compat\"")); + assert!(state_source.contains("\"--tree\"")); + assert!(!state_source.contains("/run/rigos/compat-bin/lsblk")); + assert!(!state_source.contains("run(\n \"lsblk\"")); +} + +#[test] +fn compat_tree_output_resolves_boot_parent_by_block_identity() { + let fixture_path = repo_path("crates/rigos-state/tests/fixtures/lsblk-tree.json"); + let fixture = fs::read_to_string(&fixture_path) + .unwrap_or_else(|error| panic!("failed to read {}: {error}", fixture_path.display())); + let observed: LsblkDocument = serde_json::from_str(&fixture) + .unwrap_or_else(|error| panic!("invalid {}: {error}", fixture_path.display())); + + let disk = boot_parent_disk(&observed, "8:18").expect("boot parent must be resolved"); + assert_eq!(disk.path, "/dev/sdb"); + assert_eq!( + disk.children + .iter() + .map(|child| child.path.as_str()) + .collect::>(), + vec!["/dev/sdb1", "/dev/sdb2", "/dev/sdb3", "/dev/sdb4"] + ); + assert!(boot_parent_disk(&observed, "8:99").is_none()); +} diff --git a/docs/local-rig-config.md b/docs/local-rig-config.md new file mode 100644 index 00000000..0dd81830 --- /dev/null +++ b/docs/local-rig-config.md @@ -0,0 +1,39 @@ +# Local Rig Profile and Flight Sheets + +RIGOS reads portable configuration only from partition one of the exact USB proven by the state verifier. The attestation records the boot ID, disk and partition major/minor identities, MBR PTUUID, partition PARTUUID, root identity and verification outcome. The config engine reruns the verifier and compares those stable identities before every read-only mount; `/dev/sdX` paths are advisory only. + +It stages bounded regular files in tmpfs before parsing and never sources configuration as shell. + +## USB layout + +```text +/rigos/rig.conf +/rigos/flight-sheets/*.json +/rigos/import/*.json +``` + +`rig.conf` controls machine identity, timezone, deterministic Flight Sheet selection and miner start policy. See `configs/rigos.conf.example`. + +`FLIGHT_SOURCE` is `native`, `import` or `interactive`. Native and import modes require one safe `FLIGHT_REF`; interactive mode forbids it and never auto-selects a file. `MINER_START_MODE` is `manual` or `on_boot`. Legacy `AUTO_START`, watchdog and split active/import selection keys are rejected. + +Flight Sheets describe only an XMRig workload: algorithm, ordered pools, TLS, a local identity reference, worker template and CPU policy. They cannot start services or contain a wallet, pool username, password or cloud credential. See `configs/flight-sheets/xmr-ssl.json`. + +## Local identities + +The operator resolves `identity_ref` on the local TTY. Values are stored only in the verified persistent state under `/var/lib/rigos/identities`, protected from normal diagnostics, and copied only into the restricted runtime `xmrig.json`. Canonical policy stores the alias, not the value. + +External `wal_id` values remain external references. They are never sent to XMRig. Confirmed mappings are local state and are not copied back to EFI_SYSTEM. + +## Offline Hive-style import + +The importer is a one-way compatibility boundary, not a Hive runtime. It accepts XMRig workload fields, ordered pool URLs, TLS, algorithm, placeholders and whitelisted CPU JSON fragments. `%URL%`, `%WAL%` and `%WORKER_NAME%` become RIGOS proposal inputs resolved locally before confirmation. + +Hive API endpoints, rig and farm IDs, rig passwords, remote access fields, tokens, unsupported miners and dangerous unknown fields are rejected. Lifecycle fields are reported but cannot alter machine start policy. Import provenance contains only a source filename, SHA-256, timestamp and redacted warnings. + +## Failure and recovery + +Parsing and validation finish before persistent or service mutation. Invalid input displays a stable code, safe location and redacted explanation. The operator may retry, inspect diagnostics, explicitly discard the import for the current boot and configure manually, or reboot. RIGOS never edits the source file. + +Configuration commits capture the current timezone and miner enabled/running state, stop XMRig, create a complete revision and atomically switch the current pointer. Timezone, unit policy and miner start are then applied in order. Failure restores the previous pointer and runtime snapshot; if restoration is incomplete XMRig remains stopped and the pending transaction is retained for boot recovery. `rigos.nomine=1` blocks mining for one boot without changing persistent policy. + +State outcomes `ready` and `grown` permit import. `limited_capacity` and every blocked outcome are negative gates with zero config, timezone or miner mutation. Positive physical Alpha.5 validation depends on the separate state issue reaching a persistent ready state. diff --git a/docs/physical-tests/alpha5-limited-capacity-negative-gate.md b/docs/physical-tests/alpha5-limited-capacity-negative-gate.md new file mode 100644 index 00000000..11c6eaff --- /dev/null +++ b/docs/physical-tests/alpha5-limited-capacity-negative-gate.md @@ -0,0 +1,22 @@ +# Alpha five limited capacity negative gate + +This is a required negative physical test, not evidence that persistent state or the positive configuration flow passed. + +## Preconditions + +- boot the Alpha.5 candidate from the exact test USB +- preserve a `limited_capacity` state outcome +- place a valid synthetic `rig.conf` and Flight Sheet on EFI_SYSTEM +- do not place credentials or corpus identifiers in the fixture + +## Expected result + +- firstboot displays the state outcome and blocks configuration +- `rigosadmin` has a locally established password so the operator can collect Issue 3 diagnostics +- no config source is mounted or parsed +- no revision, policy, XMRig config, identity or mapping is written +- timezone is unchanged +- miner enabled and running states are unchanged +- XMRig does not start + +Capture the redacted state status, unit state, timezone and hashes or absence of state files. Positive config-flow testing remains blocked by the separate state issue until the same machine reports `ready` or `grown` across reboot. diff --git a/docs/usb-image-build.md b/docs/usb-image-build.md index bf22c7bd..b90105f2 100644 --- a/docs/usb-image-build.md +++ b/docs/usb-image-build.md @@ -1,7 +1,7 @@ -# RIGOS 0.0.4-alpha.4 USB Appliance +# RIGOS 0.0.4-alpha.5 USB Appliance The authoritative persistent artifact is -`rigos-usb-amd64-0.0.4-alpha.4.img`. +`rigos-usb-amd64-0.0.4-alpha.5.img`. It is a raw MBR image for Rufus DD mode or `dd`. @@ -25,12 +25,11 @@ grow partition 4 after proving the exact boot USB, deterministic MBR disk signature, partition starts, partition types, active flag, filesystem labels and parent block-device topology. -`rigos-recovery-amd64-0.0.4-alpha.4.iso` is stateless recovery media. It is not +`rigos-recovery-amd64-0.0.4-alpha.5.iso` is stateless recovery media. It is not a persistent appliance and never runs the state grow helper. -Alpha four fixes the first-boot terminal stream contract found during physical -Dell 07GP33 testing. Whiptail keeps stdout attached to tty1 for screen rendering -and returns selected values through stderr, which the helper captures. +Alpha five preserves the verified alpha four terminal stream contract and adds +local-only rig profiles, portable XMRig Flight Sheets and offline import. Build from a tracked-clean commit: @@ -38,7 +37,7 @@ Build from a tracked-clean commit: podman build -t rigos-usb-builder -f build/usb/Dockerfile . podman run --rm --privileged \ -v "$PWD:/source" -v /var/tmp/rigos-build:/work rigos-usb-builder -sha256sum -c dist/usb/rigos-usb-amd64-0.0.4-alpha.4.img.sha256 +sha256sum -c dist/usb/rigos-usb-amd64-0.0.4-alpha.5.img.sha256 ``` The builder exports `HEAD` with `git archive`. Untracked workspace files cannot @@ -51,7 +50,8 @@ Historical evidence: 0.0.4-alpha.2 MBR image reached GRUB ROOT_A multi-user and password setup 0.0.4-alpha.3 booted normally but stdout piping hid every whiptail screen 0.0.4-alpha.4 terminal stream repair awaiting image and physical retest +0.0.4-alpha.5 local profile and Flight Sheet engine awaiting image test ``` -Alpha four is not a release candidate. Physical boot, state, internal disk, +Alpha five is not a release candidate. Physical boot, state, internal disk, pool, power-loss and USB write gates still apply. diff --git a/schemas/flight-sheet-v1.schema.json b/schemas/flight-sheet-v1.schema.json new file mode 100644 index 00000000..5ebd9fdc --- /dev/null +++ b/schemas/flight-sheet-v1.schema.json @@ -0,0 +1,115 @@ +{ + "$defs": { + "CpuPolicy": { + "properties": { + "huge_pages": { + "type": "boolean" + }, + "max_threads_hint": { + "format": "uint8", + "maximum": 255, + "minimum": 0, + "type": "integer" + }, + "threads": { + "$ref": "#/$defs/Threads" + } + }, + "required": [ + "threads", + "huge_pages", + "max_threads_hint" + ], + "type": "object" + }, + "Pool": { + "properties": { + "host": { + "type": "string" + }, + "port": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": "integer" + }, + "priority": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": "integer" + }, + "tls": { + "type": "boolean" + } + }, + "required": [ + "host", + "port", + "tls", + "priority" + ], + "type": "object" + }, + "Threads": { + "anyOf": [ + { + "type": "string" + }, + { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": "integer" + } + ] + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "algorithm": { + "type": "string" + }, + "backend": { + "type": "string" + }, + "coin": { + "type": "string" + }, + "cpu": { + "$ref": "#/$defs/CpuPolicy" + }, + "identity_ref": { + "type": "string" + }, + "name": { + "type": "string" + }, + "pools": { + "items": { + "$ref": "#/$defs/Pool" + }, + "type": "array" + }, + "schema": { + "type": "string" + }, + "worker_template": { + "type": "string" + } + }, + "required": [ + "schema", + "name", + "coin", + "backend", + "algorithm", + "pools", + "identity_ref", + "worker_template", + "cpu" + ], + "title": "FlightSheet", + "type": "object" +} diff --git a/schemas/identity-v1.schema.json b/schemas/identity-v1.schema.json new file mode 100644 index 00000000..c05897be --- /dev/null +++ b/schemas/identity-v1.schema.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "alias": { + "type": "string" + }, + "created_locally": { + "type": "boolean" + }, + "kind": { + "type": "string" + }, + "schema": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "schema", + "alias", + "kind", + "value", + "created_locally" + ], + "title": "IdentityRecord", + "type": "object" +} diff --git a/schemas/rig-profile-v1.schema.json b/schemas/rig-profile-v1.schema.json new file mode 100644 index 00000000..423fabd8 --- /dev/null +++ b/schemas/rig-profile-v1.schema.json @@ -0,0 +1,48 @@ +{ + "$defs": { + "FlightSource": { + "enum": [ + "native", + "import", + "interactive" + ], + "type": "string" + }, + "MinerStartMode": { + "enum": [ + "manual", + "on_boot" + ], + "type": "string" + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "flight_ref": { + "type": [ + "string", + "null" + ] + }, + "flight_source": { + "$ref": "#/$defs/FlightSource" + }, + "miner_start_mode": { + "$ref": "#/$defs/MinerStartMode" + }, + "node_name": { + "type": "string" + }, + "timezone": { + "type": "string" + } + }, + "required": [ + "node_name", + "timezone", + "flight_source", + "miner_start_mode" + ], + "title": "RigProfile", + "type": "object" +} diff --git a/scripts/build-usb-image.sh b/scripts/build-usb-image.sh index 9d37be42..77850cfd 100755 --- a/scripts/build-usb-image.sh +++ b/scripts/build-usb-image.sh @@ -26,7 +26,7 @@ export RUSTFLAGS='-C target-cpu=x86-64' export SOURCE_DATE_EPOCH="$source_epoch" export CARGO_TARGET_DIR="$work/target" cd "$source_root" -cargo build --release --locked -p rigosd -p rigos-state +cargo build --release --locked -p rigosd -p rigos-state -p rigos-config mkdir -p "$live/config/package-lists" "$live/config/includes.chroot" \ "$live/config/hooks/live" "$live/config/bootloaders" @@ -37,6 +37,7 @@ cp -a build/usb/bootloaders/. "$live/config/bootloaders/" install -D -m 0755 "$CARGO_TARGET_DIR/release/rigosd" "$live/config/includes.chroot/usr/lib/rigos/rigosd" ln -s rigosd "$live/config/includes.chroot/usr/lib/rigos/rigosctl" install -D -m 0755 "$CARGO_TARGET_DIR/release/rigos-state-init" "$live/config/includes.chroot/usr/lib/rigos/rigos-state-init" +install -D -m 0755 "$CARGO_TARGET_DIR/release/rigos-config" "$live/config/includes.chroot/usr/lib/rigos/rigos-config" ln -s /run/live/medium/image-layout.json "$live/config/includes.chroot/usr/lib/rigos/image-layout.json" xmrig_archive="xmrig-${RIGOS_XMRIG_VERSION}-linux-static-x64.tar.gz" diff --git a/scripts/verify-usb-appliance.sh b/scripts/verify-usb-appliance.sh index dfc07fb4..e8863a1a 100755 --- a/scripts/verify-usb-appliance.sh +++ b/scripts/verify-usb-appliance.sh @@ -73,15 +73,31 @@ cmp "$temporary/a/image-layout.json" "$temporary/b/image-layout.json" [[ "$(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" \ - etc/rigos-release etc/os-release usr/lib/rigos/rigosd usr/lib/rigos/rigosctl \ - usr/lib/rigos/rigos-state-init usr/lib/rigos/xmrig usr/local/sbin/rigos-firstboot \ + etc/rigos-release etc/os-release \ + etc/systemd/system/rigos-state.service \ + etc/systemd/system/rigos-miner.service \ + etc/systemd/system/rigos-profile-apply.service \ + 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-config usr/lib/rigos/xmrig usr/local/sbin/rigos-firstboot \ 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' python3 -m py_compile "$temporary/root/usr/local/sbin/rigos-firstboot" +grep -Fq 'RuntimeDirectory=rigos' "$temporary/root/etc/systemd/system/rigos-state.service" || die 'state runtime directory is missing' +grep -Fq 'RuntimeDirectoryMode=0755' "$temporary/root/etc/systemd/system/rigos-state.service" || die 'state runtime directory mode is missing' +if grep -Eq '^(ExecStartPre|Environment=PATH)=.*compat-bin' "$temporary/root/etc/systemd/system/rigos-state.service"; then die 'state unit executes compatibility code from the runtime directory'; fi +[[ -f "$temporary/root/usr/lib/rigos/lsblk-compat" ]] || die 'state lsblk compatibility wrapper is missing' +[[ -x "$temporary/root/usr/bin/python3" ]] || die 'Python runtime for state compatibility is missing' +strings "$temporary/root/usr/lib/rigos/rigos-state-init" | grep -F '/usr/bin/python3' >/dev/null || die 'state initializer does not use the absolute Python runtime' +strings "$temporary/root/usr/lib/rigos/rigos-state-init" | grep -F '/usr/lib/rigos/lsblk-compat' >/dev/null || die 'state initializer does not use the packaged compatibility wrapper' +strings "$temporary/root/usr/lib/rigos/rigos-state-init" | grep -F -- '--tree' >/dev/null || die 'state initializer does not require hierarchical lsblk output' +if strings "$temporary/root/usr/lib/rigos/rigos-state-init" | grep -F '/run/rigos/compat-bin/lsblk' >/dev/null; then die 'state initializer executes compatibility code from the runtime directory'; fi +grep -Fq 'ExecCondition=/usr/lib/rigos/rigos-config gate' "$temporary/root/etc/systemd/system/rigos-miner.service" || die 'miner policy gate is missing' +[[ -f "$temporary/root/etc/systemd/system/rigos-profile-apply.service" ]] || die 'profile apply service is missing' +grep -Fq 'rigos-config recover' "$temporary/root/etc/systemd/system/rigos-profile-apply.service" || die 'pending transaction recovery is missing' if rg -q -- '--output-fd' "$temporary/root/usr/local/sbin/rigos-firstboot"; then die 'first boot rewires the whiptail screen stream'; fi grep -Fq 'stderr=subprocess.PIPE' "$temporary/root/usr/local/sbin/rigos-firstboot" || die 'first boot stderr capture is missing' -if grep -Fq 'stdout=subprocess.PIPE' "$temporary/root/usr/local/sbin/rigos-firstboot"; then die 'first boot hides the whiptail screen'; fi grep -Fq 'return result.stderr.strip()' "$temporary/root/usr/local/sbin/rigos-firstboot" || die 'first boot value stream mismatch' [[ "$(jq -r .modified "$temporary/root/usr/share/rigos/components/xmrig.json")" == false ]] [[ "$(jq -r .upstream_donation_behavior "$temporary/root/usr/share/rigos/components/xmrig.json")" == applies ]] diff --git a/scripts/verify.sh b/scripts/verify.sh index 3f09b972..03bf2d50 100755 --- a/scripts/verify.sh +++ b/scripts/verify.sh @@ -13,6 +13,22 @@ cargo build --workspace --release --locked bash -n scripts/*.sh build/usb/hooks/*.chroot python3 -m py_compile build/usb/includes.chroot/usr/local/sbin/rigos-firstboot +grep -Fq 'RIGOS_CONFIG_DUPLICATE_KEY' crates/rigos-config/src/lib.rs +grep -Fq 'RIGOS_CONFIG_BOOT_DEVICE_UNPROVEN' crates/rigos-config/src/main.rs +grep -Fq 'ro,nodev,nosuid,noexec' crates/rigos-config/src/main.rs +grep -Fq 'rigos-state-init' crates/rigos-config/src/main.rs +grep -Fq 'boot_id' crates/rigos-state/src/main.rs +grep -Fq 'major_minor' crates/rigos-state/src/main.rs +grep -Fq 'ptuuid' crates/rigos-state/src/main.rs +grep -Fq 'partuuid' crates/rigos-state/src/main.rs +grep -Fq '.pending-transaction.json' crates/rigos-config/src/main.rs +grep -Fq 'engine("transact"' build/usb/includes.chroot/usr/local/sbin/rigos-firstboot +grep -Fq 'ExecCondition=/usr/lib/rigos/rigos-config gate' build/usb/includes.chroot/etc/systemd/system/rigos-miner.service +if rg -n '(HIVE_HOST_URL|API_HOST_URLS|RIG_PASSWD|HSSH_SRV)=' configs docs/local-rig-config.md; then + echo "Hive cloud or rig credential field leaked into RIGOS configuration" >&2 + exit 1 +fi + firstboot=build/usb/includes.chroot/usr/local/sbin/rigos-firstboot if rg -q -- '--output-fd' "$firstboot"; then echo "first boot redirects whiptail result onto the screen stream" >&2 @@ -22,10 +38,6 @@ if ! grep -Fq 'stderr=subprocess.PIPE' "$firstboot"; then echo "first boot does not capture whiptail values from stderr" >&2 exit 1 fi -if grep -Fq 'stdout=subprocess.PIPE' "$firstboot"; then - echo "first boot hides the whiptail screen" >&2 - exit 1 -fi if ! grep -Fq 'return result.stderr.strip()' "$firstboot"; then echo "first boot does not read the selected value from stderr" >&2 exit 1 @@ -66,6 +78,42 @@ if "stdout" in seen["kwargs"]: raise SystemExit("first boot dialog does not leave stdout on tty") PY +python3 - "$firstboot" <<'PY' +import json +import runpy +import sys +import tempfile +from pathlib import Path + +source = Path(sys.argv[1]).read_text(encoding="utf-8") +main_source = source[source.index("def main() -> None:"):] +if main_source.index("ensure_administrator_password()") > main_source.index('if state_outcome not in {"ready", "grown"}'): + raise SystemExit("administrator password is established after the state gate") + +namespace = runpy.run_path(sys.argv[1], run_name="rigos_firstboot_identity_verify") +with tempfile.TemporaryDirectory() as temporary: + state = Path(temporary) + identities = state / "identities" + identities.mkdir() + identity = {"schema":"rigos.identity/v1","alias":"mapped-local","kind":"mining_identity","value":"fixture-private-value","created_locally":True} + (identities / "mapped-local.json").write_text(json.dumps(identity), encoding="utf-8") + mapping = {"schema":"rigos.external-identity-map/v1","mappings":[{"source":"hive-style","external_type":"wal_id","external_value":"fixture-external-ref","identity_ref":"mapped-local","confirmed_source_sha256":"fixture-hash"}]} + (state / "external-identity-map.json").write_text(json.dumps(mapping), encoding="utf-8") + function_globals = namespace["resolve_identity"].__globals__ + function_globals["STATE"] = state + function_globals["confirm"] = lambda _message: True + proposal = {"source_sha256":"fixture-hash","provenance":{"external_reference":{"source":"hive-style","external_type":"wal_id","external_value":"fixture-external-ref"}},"flight_sheet":{"identity_ref":"unresolved"}} + selected = namespace["resolve_identity"](proposal) + if selected["alias"] != "mapped-local" or proposal["flight_sheet"]["identity_ref"] != "mapped-local": + raise SystemExit("confirmed external identity mapping was not reused") + + function_globals["dialog"] = lambda *_args: "existing:mapped-local" + proposal = {"source_sha256":"native","provenance":None,"flight_sheet":{"identity_ref":"different-native-alias"}} + selected = namespace["resolve_identity"](proposal) + if selected["alias"] != "mapped-local" or proposal["flight_sheet"]["identity_ref"] != "mapped-local": + raise SystemExit("selected identity alias did not update the proposal") +PY + if ! rg -q 'label: dos' scripts/build-usb-image.sh; then echo "MBR appliance table declaration missing" >&2 exit 1