Skip to content
Original file line number Diff line number Diff line change
@@ -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
Expand Down
6 changes: 3 additions & 3 deletions build/usb/version.env
Original file line number Diff line number Diff line change
@@ -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
55 changes: 55 additions & 0 deletions crates/rigos-config/tests/firstboot_image_gate.rs
Original file line number Diff line number Diff line change
@@ -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"));
}
4 changes: 4 additions & 0 deletions crates/rigos-config/tests/firstboot_theme.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand Down
40 changes: 40 additions & 0 deletions crates/rigos-config/tests/firstboot_tty.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
2 changes: 2 additions & 0 deletions scripts/build-usb-image-entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
113 changes: 113 additions & 0 deletions scripts/verify-firstboot-image.sh
Original file line number Diff line number Diff line change
@@ -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>'
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"
Loading