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 4a48315..9b87321 100644 --- a/build/usb/includes.chroot/etc/systemd/system/rigos-firstboot.service +++ b/build/usb/includes.chroot/etc/systemd/system/rigos-firstboot.service @@ -1,15 +1,14 @@ [Unit] Description=RIGOS first-boot configuration -After=network-online.target rigos-state-ready.service rigos-profile-apply.service -Wants=network-online.target -Requires=rigos-state-ready.service +After=rigos-state.service rigos-state-ready.service rigos-profile-apply.service +Wants=rigos-state-ready.service Before=getty@tty1.service [Service] Type=oneshot ExecCondition=/usr/lib/rigos/rigos-config needs-activation ExecStart=/usr/local/sbin/rigos-firstboot -StandardInput=tty +StandardInput=tty-force StandardOutput=tty StandardError=tty TTYPath=/dev/tty1 diff --git a/build/usb/version.env b/build/usb/version.env index 9567e61..c85c441 100644 --- a/build/usb/version.env +++ b/build/usb/version.env @@ -1,8 +1,8 @@ -RIGOS_PRODUCT_VERSION=0.0.4-alpha.8 -RIGOS_IMAGE_VERSION=0.0.4-alpha.8 +RIGOS_PRODUCT_VERSION=0.0.4-alpha.9 +RIGOS_IMAGE_VERSION=0.0.4-alpha.9 RIGOS_IMAGE_ID=rigos-usb-amd64 RIGOS_IMAGE_CHANNEL=alpha -RIGOS_BUILD_ORDINAL=8 +RIGOS_BUILD_ORDINAL=9 RIGOS_XMRIG_VERSION=6.26.0 RIGOS_XMRIG_ARCHIVE_SHA256=fc6f8ae5f64e4f17481f7e3be29a1c56949f216a998414188003eae1db20c9e5 RIGOS_XMRIG_BINARY_SHA256=b20f39fc00d242e706b6c30367ad811c676e0575050a4ec2f30104b696944b49 diff --git a/crates/rigos-config/tests/firstboot_image_gate.rs b/crates/rigos-config/tests/firstboot_image_gate.rs new file mode 100644 index 0000000..e351a43 --- /dev/null +++ b/crates/rigos-config/tests/firstboot_image_gate.rs @@ -0,0 +1,55 @@ +use std::fs; +use std::path::PathBuf; + +fn repo_file(path: &str) -> String { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .join(path); + fs::read_to_string(&path) + .unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display())) +} + +#[test] +fn alpha9_build_runs_source_and_exact_image_firstboot_gates() { + let version = repo_file("build/usb/version.env"); + let entrypoint = repo_file("scripts/build-usb-image-entrypoint.sh"); + let verifier = repo_file("scripts/verify-firstboot-image.sh"); + let hook = repo_file("build/usb/hooks/010-rigos.chroot"); + + assert!(version.contains("RIGOS_PRODUCT_VERSION=0.0.4-alpha.9")); + assert!(version.contains("RIGOS_IMAGE_VERSION=0.0.4-alpha.9")); + assert!(version.contains("RIGOS_BUILD_ORDINAL=9")); + + assert!(entrypoint.contains("--test firstboot_tty")); + assert!(entrypoint.contains("bash ./scripts/verify-firstboot-image.sh \"$image\"")); + + for required in [ + "multi-user.target.wants/rigos-firstboot.service", + "firstboot service is not enabled in the appliance", + "state readiness failure still suppresses firstboot diagnostics", + "firstboot still depends on network-online", + "StandardInput=tty-force", + "def manual_proposal()", + ] { + assert!( + verifier.contains(required), + "firstboot exact-image verifier is missing: {required}" + ); + } + + assert!( + hook.contains("rigos-firstboot.service"), + "image construction must enable firstboot" + ); +} + +#[test] +fn firstboot_image_gate_is_read_only_against_the_appliance_image() { + let verifier = repo_file("scripts/verify-firstboot-image.sh"); + + assert!(verifier.contains("losetup --find --show --read-only")); + assert!(verifier.contains("mount -o ro")); + assert!(!verifier.contains("mount -o rw")); + assert!(!verifier.contains("systemctl start")); + assert!(!verifier.contains("systemctl enable")); +} diff --git a/crates/rigos-config/tests/firstboot_theme.rs b/crates/rigos-config/tests/firstboot_theme.rs index 6a494b4..f6f6e9f 100644 --- a/crates/rigos-config/tests/firstboot_theme.rs +++ b/crates/rigos-config/tests/firstboot_theme.rs @@ -77,6 +77,10 @@ fn authoritative_wsl_gate_runs_the_wrapper_behavior_test() { let verifier = repo_file("scripts/verify-firstboot-theme-wrapper.sh"); assert!(entrypoint.contains("bash ./scripts/verify-firstboot-theme-wrapper.sh")); + assert!( + entrypoint.contains("cmp diff jq"), + "WSL preflight must require jq before runtime publication tests" + ); assert!(verifier.contains("RIGOS firstboot theme wrapper verification passed")); assert!(verifier.contains("RIGOS_THEME_EXIT=7")); assert!(verifier.contains("missing-backend")); diff --git a/crates/rigos-config/tests/firstboot_tty.rs b/crates/rigos-config/tests/firstboot_tty.rs index 414101a..4f3d1aa 100644 --- a/crates/rigos-config/tests/firstboot_tty.rs +++ b/crates/rigos-config/tests/firstboot_tty.rs @@ -41,6 +41,46 @@ fn firstboot_releases_tty1_to_getty_after_exit() { ); } +#[test] +fn firstboot_remains_visible_when_state_readiness_fails() { + let unit = unit("rigos-firstboot.service"); + let after = directive_tokens(&unit, "After="); + let wants = directive_tokens(&unit, "Wants="); + let requires = directive_tokens(&unit, "Requires="); + + for ordered in [ + "rigos-state.service", + "rigos-state-ready.service", + "rigos-profile-apply.service", + ] { + assert!( + after.contains(ordered), + "firstboot must start after {ordered} finishes, including failure" + ); + } + + assert!( + wants.contains("rigos-state-ready.service"), + "firstboot must request state verification without depending on success" + ); + assert!( + !requires.contains("rigos-state-ready.service"), + "state verification failure must not suppress firstboot diagnostics" + ); + assert!( + !unit.contains("network-online.target"), + "local firstboot must remain available without network connectivity" + ); + assert!( + unit.lines().any(|line| line == "StandardInput=tty-force"), + "firstboot must acquire tty1 for the local setup flow" + ); + assert!( + unit.lines().any(|line| line == "TTYPath=/dev/tty1"), + "firstboot must remain bound to tty1" + ); +} + #[test] fn recovery_access_does_not_hang_up_the_following_firstboot_session() { let unit = unit("rigos-recovery-access.service"); diff --git a/scripts/build-usb-image-entrypoint.sh b/scripts/build-usb-image-entrypoint.sh index 78e1ef7..2970c3c 100644 --- a/scripts/build-usb-image-entrypoint.sh +++ b/scripts/build-usb-image-entrypoint.sh @@ -44,9 +44,11 @@ export CARGO_TARGET_DIR=/work/rigos-performance-preflight-target cargo test --locked -p rigos-config --test miner_observer_authority -- --nocapture cargo test --locked -p rigos-config --test randomx_build_entrypoint -- --nocapture cargo test --locked -p rigos-config --test randomx_msr_authority -- --nocapture +cargo test --locked -p rigos-config --test firstboot_tty -- --nocapture ./scripts/build-usb-image.sh image="./dist/usb/${RIGOS_IMAGE_ID}-${RIGOS_IMAGE_VERSION}.img" bash ./scripts/verify-randomx-performance-image.sh "$image" bash ./scripts/verify-miner-observer-image.sh "$image" +bash ./scripts/verify-firstboot-image.sh "$image" diff --git a/scripts/verify-firstboot-image.sh b/scripts/verify-firstboot-image.sh new file mode 100644 index 0000000..3e73c19 --- /dev/null +++ b/scripts/verify-firstboot-image.sh @@ -0,0 +1,113 @@ +#!/bin/bash +set -euo pipefail + +die() { + printf 'verify-firstboot-image: %s\n' "$*" >&2 + exit 1 +} + +[[ $# -eq 1 ]] || die 'usage: verify-firstboot-image.sh ' +image="$(readlink -f "$1")" +[[ -f "$image" ]] || die "image is missing: $image" +[[ "$(id -u)" -eq 0 ]] || die 'must run as root' + +partition_json="$(sfdisk --json "$image")" +start="$(jq -r '.partitiontable.partitions[1].start' <<<"$partition_json")" +size="$(jq -r '.partitiontable.partitions[1].size' <<<"$partition_json")" +[[ "$start" =~ ^[0-9]+$ && "$size" =~ ^[0-9]+$ ]] || die 'ROOT_A geometry is invalid' + +loop="$( + losetup --find --show --read-only \ + --offset $((start * 512)) \ + --sizelimit $((size * 512)) \ + "$image" +)" +temporary="$(mktemp -d)" +cleanup() { + set +e + mountpoint -q "$temporary/root-a" && umount "$temporary/root-a" + losetup -d "$loop" 2>/dev/null + rm -rf "$temporary" +} +trap cleanup EXIT + +mkdir -p "$temporary/root-a" "$temporary/squash" +mount -o ro "$loop" "$temporary/root-a" +squashfs="$temporary/root-a/live/filesystem.squashfs" +[[ -f "$squashfs" ]] || die 'ROOT_A squashfs is missing' + +unsquashfs -no-progress -d "$temporary/squash" "$squashfs" \ + usr/bin/python3 usr/bin/python3.11 usr/bin/whiptail \ + usr/local/sbin/rigos-firstboot \ + usr/lib/rigos/rigos-firstboot-whiptail \ + etc/systemd/system/rigos-firstboot.service \ + etc/systemd/system/rigos-firstboot.service.d/2009-console-theme.conf \ + etc/systemd/system/multi-user.target.wants/rigos-firstboot.service \ + >/dev/null + +root="$temporary/squash" +service="$root/etc/systemd/system/rigos-firstboot.service" +dropin="$root/etc/systemd/system/rigos-firstboot.service.d/2009-console-theme.conf" +firstboot="$root/usr/local/sbin/rigos-firstboot" +wrapper="$root/usr/lib/rigos/rigos-firstboot-whiptail" + +[[ -L "$root/etc/systemd/system/multi-user.target.wants/rigos-firstboot.service" ]] \ + || die 'firstboot service is not enabled in the appliance' +[[ -x "$root/usr/bin/python3" ]] || die 'Python runtime for firstboot is missing' +[[ -x "$root/usr/bin/whiptail" ]] || die 'whiptail runtime is missing' +[[ -x "$firstboot" ]] || die 'firstboot program is missing or not executable' +[[ -x "$wrapper" ]] || die 'firstboot theme wrapper is missing or not executable' +[[ -f "$dropin" ]] || die 'firstboot theme drop-in is missing' + +python3 -m py_compile "$firstboot" +sh -n "$wrapper" + +for required in \ + 'After=rigos-state.service rigos-state-ready.service rigos-profile-apply.service' \ + 'Wants=rigos-state-ready.service' \ + 'Before=getty@tty1.service' \ + 'ExecCondition=/usr/lib/rigos/rigos-config needs-activation' \ + 'ExecStart=/usr/local/sbin/rigos-firstboot' \ + 'StandardInput=tty-force' \ + 'StandardOutput=tty' \ + 'StandardError=tty' \ + 'TTYPath=/dev/tty1' \ + 'TTYReset=yes' \ + 'TTYVTDisallocate=yes' +do + grep -Fqx "$required" "$service" \ + || die "firstboot service contract is missing: $required" +done + +if grep -Fqx 'Requires=rigos-state-ready.service' "$service"; then + die 'state readiness failure still suppresses firstboot diagnostics' +fi +if grep -Fq 'network-online.target' "$service"; then + die 'firstboot still depends on network-online' +fi + +for required in \ + "stage='state_not_ready'" \ + "raise FirstbootFailure('state_not_ready')" \ + 'def manual_proposal()' \ + "('manual', 'Configure manually')" \ + "raise FirstbootCancelled('mining_left_unconfigured')" +do + grep -Fq "$required" "$firstboot" \ + || die "firstboot recovery/configuration path is missing: $required" +done + +for required in \ + 'Environment=RIGOS_WHIPTAIL=/usr/lib/rigos/rigos-firstboot-whiptail' \ + 'RIGOS SETUP UTILITY LOCAL NODE CONFIGURATION' +do + grep -Fq "$required" "$dropin" \ + || die "firstboot theme drop-in contract is missing: $required" +done + +grep -Fq 'RIGOS_WHIPTAIL_REAL:-/usr/bin/whiptail' "$wrapper" \ + || die 'firstboot theme wrapper does not use packaged whiptail' +grep -Fq 'exec "$whiptail_real"' "$wrapper" \ + || die 'firstboot theme wrapper does not preserve the dialog engine' + +printf 'RIGOS firstboot exact-image verification passed: %s\n' "$image" diff --git a/scripts/verify-systemd-ordering.py b/scripts/verify-systemd-ordering.py index a6f8443..67e13d1 100644 --- a/scripts/verify-systemd-ordering.py +++ b/scripts/verify-systemd-ordering.py @@ -42,51 +42,145 @@ def includes(actual, expected, message): def verify(units): names = { - "rigos-state.service", "rigos-recovery-access.service", - "rigos-state-ready.service", "rigos-ssh-hostkeys.service", - "rigos-profile-apply.service", "rigos-firstboot.service", - "rigos-hugepages.service", "rigos-miner.service", + "rigos-state.service", + "rigos-recovery-access.service", + "rigos-state-ready.service", + "rigos-ssh-hostkeys.service", + "rigos-profile-apply.service", + "rigos-firstboot.service", + "rigos-hugepages.service", + "rigos-miner.service", } includes(set(units), names, "RIGOS unit set is incomplete") state = units["rigos-state.service"] - require(state.scalar("Unit", "DefaultDependencies") == "no", "state must retain early boot dependencies") - includes(state.words("Unit", "Before"), {"local-fs.target", "rigos-state-ready.service"}, "state ordering is incomplete") + require( + state.scalar("Unit", "DefaultDependencies") == "no", + "state must retain early boot dependencies", + ) + includes( + state.words("Unit", "Before"), + {"local-fs.target", "rigos-state-ready.service"}, + "state ordering is incomplete", + ) recovery = units["rigos-recovery-access.service"] - includes(recovery.words("Unit", "After"), {"rigos-state.service"}, "recovery must follow state") - includes(recovery.words("Unit", "Before"), {"rigos-state-ready.service", "rigos-firstboot.service"}, "recovery ordering is incomplete") - require(recovery.scalar("Service", "TTYVHangup") != "yes", "recovery must not hang up tty1") + includes( + recovery.words("Unit", "After"), + {"rigos-state.service"}, + "recovery must follow state", + ) + includes( + recovery.words("Unit", "Before"), + {"rigos-state-ready.service", "rigos-firstboot.service"}, + "recovery ordering is incomplete", + ) + require( + recovery.scalar("Service", "TTYVHangup") != "yes", + "recovery must not hang up tty1", + ) ready = units["rigos-state-ready.service"] - require(ready.scalar("Unit", "DefaultDependencies") != "no", "state-ready must use normal dependencies") - includes(ready.words("Unit", "After"), {"rigos-state.service", "rigos-recovery-access.service"}, "state-ready ordering is incomplete") - includes(ready.words("Unit", "Requires"), {"rigos-state.service"}, "state-ready must require state") + require( + ready.scalar("Unit", "DefaultDependencies") != "no", + "state-ready must use normal dependencies", + ) + includes( + ready.words("Unit", "After"), + {"rigos-state.service", "rigos-recovery-access.service"}, + "state-ready ordering is incomplete", + ) + includes( + ready.words("Unit", "Requires"), + {"rigos-state.service"}, + "state-ready must require state", + ) includes( ready.words("Unit", "Before"), { - "rigos-ssh-hostkeys.service", "rigos-profile-apply.service", - "rigos-firstboot.service", "rigos-hugepages.service", + "rigos-ssh-hostkeys.service", + "rigos-profile-apply.service", + "rigos-firstboot.service", + "rigos-hugepages.service", "rigos-miner.service", }, "state-ready downstream ordering is incomplete", ) - require("local-fs.target" not in ready.words("Unit", "Before"), "state-ready must not order before local-fs") - require("local-fs.target" not in ready.words("Install", "WantedBy"), "state-ready must not be installed under local-fs") - includes(ready.words("Install", "WantedBy"), {"multi-user.target"}, "state-ready must be installed under multi-user") + require( + "local-fs.target" not in ready.words("Unit", "Before"), + "state-ready must not order before local-fs", + ) + require( + "local-fs.target" not in ready.words("Install", "WantedBy"), + "state-ready must not be installed under local-fs", + ) + includes( + ready.words("Install", "WantedBy"), + {"multi-user.target"}, + "state-ready must be installed under multi-user", + ) hostkeys = units["rigos-ssh-hostkeys.service"] - includes(hostkeys.words("Unit", "After"), {"rigos-state-ready.service"}, "SSH host-key authority must follow state readiness") - includes(hostkeys.words("Unit", "Requires"), {"rigos-state-ready.service"}, "SSH host-key authority must require state readiness") - includes(hostkeys.words("Unit", "Before"), {"ssh.service"}, "SSH host-key authority must precede sshd") + includes( + hostkeys.words("Unit", "After"), + {"rigos-state-ready.service"}, + "SSH host-key authority must follow state readiness", + ) + includes( + hostkeys.words("Unit", "Requires"), + {"rigos-state-ready.service"}, + "SSH host-key authority must require state readiness", + ) + includes( + hostkeys.words("Unit", "Before"), + {"ssh.service"}, + "SSH host-key authority must precede sshd", + ) require( hostkeys.scalar("Service", "ExecStart") == "/usr/lib/rigos/rigos-ssh-hostkeys", "SSH host-key authority entrypoint is not exact", ) - includes(hostkeys.words("Install", "WantedBy"), {"multi-user.target"}, "SSH host-key authority must be enabled under multi-user") + includes( + hostkeys.words("Install", "WantedBy"), + {"multi-user.target"}, + "SSH host-key authority must be enabled under multi-user", + ) firstboot = units["rigos-firstboot.service"] - includes(firstboot.words("Unit", "Requires"), {"rigos-state-ready.service"}, "firstboot must require state-ready") - require(firstboot.scalar("Service", "TTYVHangup") != "yes", "firstboot must not hang up tty1") + includes( + firstboot.words("Unit", "After"), + { + "rigos-state.service", + "rigos-state-ready.service", + "rigos-profile-apply.service", + }, + "firstboot ordering is incomplete", + ) + includes( + firstboot.words("Unit", "Wants"), + {"rigos-state-ready.service"}, + "firstboot must request state verification", + ) + require( + "rigos-state-ready.service" not in firstboot.words("Unit", "Requires"), + "firstboot diagnostics must survive state readiness failure", + ) + require( + "network-online.target" not in firstboot.words("Unit", "After") + and "network-online.target" not in firstboot.words("Unit", "Wants"), + "firstboot must remain available offline", + ) + require( + firstboot.scalar("Service", "StandardInput") == "tty-force", + "firstboot must acquire tty1", + ) + require( + firstboot.scalar("Service", "TTYPath") == "/dev/tty1", + "firstboot must use tty1", + ) + require( + firstboot.scalar("Service", "TTYVHangup") != "yes", + "firstboot must not hang up tty1", + ) def graph_for(units): @@ -120,7 +214,7 @@ def visit(node): if cycle: return cycle elif state[target] == 1: - return stack[positions[target]:] + [target] + return stack[positions[target] :] + [target] stack.pop() positions.pop(node) state[node] = 2 @@ -136,7 +230,11 @@ def visit(node): def main(): parser = argparse.ArgumentParser() - parser.add_argument("unit_dir", nargs="?", default="build/usb/includes.chroot/etc/systemd/system") + parser.add_argument( + "unit_dir", + nargs="?", + default="build/usb/includes.chroot/etc/systemd/system", + ) directory = Path(parser.parse_args().unit_dir) try: units = {path.name: Unit(path) for path in directory.glob("rigos-*.service")} diff --git a/scripts/verify-wsl-entrypoint.sh b/scripts/verify-wsl-entrypoint.sh index 27e1693..7f50da9 100644 --- a/scripts/verify-wsl-entrypoint.sh +++ b/scripts/verify-wsl-entrypoint.sh @@ -20,7 +20,7 @@ if [[ -f "$HOME/.cargo/env" ]]; then fi missing=0 -for tool in cargo rustc python3 bash sh git grep rg mktemp cmp diff; do +for tool in cargo rustc python3 bash sh git grep rg mktemp cmp diff jq; do if ! command -v "$tool" >/dev/null 2>&1; then printf 'RIGOS_WSL_TOOL_MISSING=%s\n' "$tool" >&2 missing=1