diff --git a/bin/omarchy-iso-test b/bin/omarchy-iso-test index 7858b3a..dd49ab6 100755 --- a/bin/omarchy-iso-test +++ b/bin/omarchy-iso-test @@ -703,7 +703,7 @@ drive_oem_setup() { capture_console "success-oem-02-password" press ret - wait_for_screen "Confirm" 60 + wait_for_screen "Conf *irm" 60 type_text "$GUEST_PASSWORD" press ret @@ -932,6 +932,25 @@ install_phase() { fi bootstrap_ssh + # SSH can come up (console bootstrap logs in on a spare TTY) while + # omarchy-oem-setup is still finalizing the user and re-keying LUKS. Saving + # the base mid-finalization produces a flaky image, so wait for the pending + # flag to clear before powering off. + if $OEM; then + log "Waiting for OEM first-boot setup to finish" + local waited=0 + while ssh_guest "test -f /var/lib/omarchy/oem/pending" 2>/dev/null; do + if ((waited >= 900)); then + capture_console "failure-oem-finalization-timeout" + echo "Timed out waiting for OEM first-boot setup to complete" >&2 + return 1 + fi + sleep 10 + ((waited += 10)) + done + capture_console "success-oem-06-setup-complete" + fi + ssh_guest "echo $GUEST_PASSWORD | sudo -S cat /var/log/omarchy-install-timing.json 2>/dev/null" \ >"$RUN_DIR/omarchy-install-timing.json" 2>/dev/null || true ssh_guest "echo $GUEST_PASSWORD | sudo -S cat /var/log/pacman.log 2>/dev/null" \ diff --git a/configs/airootfs/usr/local/bin/omarchy-cidata-load b/configs/airootfs/usr/local/bin/omarchy-cidata-load index d8e83e9..cabc210 100755 --- a/configs/airootfs/usr/local/bin/omarchy-cidata-load +++ b/configs/airootfs/usr/local/bin/omarchy-cidata-load @@ -38,10 +38,20 @@ done mkdir -p "$mnt" mount -o ro "$device" "$mnt" || exit 1 +# Clear any inputs a previous load in this live session left in /root before +# copying the current drive: a stale `oem` marker or credentials file would +# otherwise force a fresh drive's config back into OEM mode or hand it an old +# LUKS passphrase. The interactive wizard writes these same names, but a +# cidata load always precedes it, so nothing of the user's is discarded here. +optional_inputs=(user_credentials.json oem user_full_name.txt user_email_address.txt user_encrypt_installation.txt authorized_keys tailscale_authkey) +for file in user_configuration.json "${optional_inputs[@]}"; do + rm -f "$dest/$file" +done + loaded=1 if [[ -f $mnt/user_configuration.json ]] && [[ -f $mnt/user_credentials.json || -f $mnt/oem ]]; then files=("$mnt"/user_configuration.json) - for file in user_credentials.json oem user_full_name.txt user_email_address.txt user_encrypt_installation.txt authorized_keys tailscale_authkey; do + for file in "${optional_inputs[@]}"; do [[ -f $mnt/$file ]] && files+=("$mnt/$file") done if cp "${files[@]}" "$dest"/; then diff --git a/configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py b/configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py index 018c559..a189e50 100644 --- a/configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py +++ b/configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py @@ -76,6 +76,14 @@ class InstallContext: arch_configuration = dict(user_configuration) arch_configuration.pop("omarchy_install", None) + + if oem: + # The userless invariant covers the archinstall config too: a + # combined/legacy config could carry account authentication that + # archinstall would still act on (users, a root password). Strip + # every such field so no account is created before first boot. + _strip_account_fields(arch_configuration) + state_dir = Path(os.environ.get("OMARCHY_INSTALL_STATE_DIR", "/run/omarchy-install")) state_dir.mkdir(parents=True, exist_ok=True) @@ -135,6 +143,20 @@ class InstallContext: return self.mode == "protected" +def _strip_account_fields(arch_configuration: dict) -> None: + """Remove every field archinstall reads to create users or set a root + password, so an OEM install cannot ship a hidden provisioning account. + Encryption material lives elsewhere (disk_config.disk_encryption) and is + untouched.""" + for key in ("!users", "!root-password", "root_enc_password", "users"): + arch_configuration.pop(key, None) + + auth = arch_configuration.get("auth_config") + if isinstance(auth, dict): + for key in ("users", "root_enc_password"): + auth.pop(key, None) + + def _inject_oem_encryption_password(arch_configuration: dict, user_credentials: dict) -> None: """Ensure an encrypted OEM install has a LUKS passphrase archinstall can use. Reuse one the input already carries (an autoinstall rig may supply diff --git a/test/cidata-load-test.sh b/test/cidata-load-test.sh index e59a67c..67e798b 100644 --- a/test/cidata-load-test.sh +++ b/test/cidata-load-test.sh @@ -153,6 +153,29 @@ attach_drive cidata [[ ! -e $sandbox/root/oem ]] || fail "oem marker alone copies nothing" pass "oem marker alone falls back to the wizard" +# Stale OEM inputs from a previous load in the same session are cleared before +# the current drive is copied: a normal drive must not inherit an old oem +# marker or credentials. +new_sandbox +attach_drive cidata +write_required_pair +: >"$sandbox/root/oem" # leftover from a prior OEM load +echo 'old-keys' >"$sandbox/root/authorized_keys" +run_load || fail "normal drive after a stale OEM load loads" +[[ ! -e $sandbox/root/oem ]] || fail "stale oem marker is cleared" +[[ ! -e $sandbox/root/authorized_keys ]] || fail "stale optional inputs are cleared" +pass "stale OEM inputs are cleared before loading a normal drive" + +# A drive that isn't an autoinstall drive at all still clears stale inputs so +# the wizard that follows doesn't inherit them. +new_sandbox +attach_drive cidata +echo '{"disk_config": {}}' >"$sandbox/media/user_configuration.json" # half a pair +: >"$sandbox/root/oem" +! run_load || fail "half-pair drive still falls back" +[[ ! -e $sandbox/root/oem ]] || fail "stale oem marker cleared even on fallback" +pass "stale inputs are cleared even when falling back to the wizard" + # Half the required pair is not an autoinstall drive: unmount and fall back. new_sandbox attach_drive cidata diff --git a/test/test_oem_state.py b/test/test_oem_state.py index 422dcd3..d683718 100644 --- a/test/test_oem_state.py +++ b/test/test_oem_state.py @@ -137,6 +137,20 @@ class ContextOemTest(unittest.TestCase): self.assertEqual(synthesized["users"], []) self.assertNotIn("root_enc_password", synthesized) + def test_oem_strips_account_fields_from_arch_config(self): + config = self.base_config(oem=True) + config["!users"] = [{"username": "rig"}] + config["!root-password"] = "hunter2" + config["root_enc_password"] = "x" + config["auth_config"] = {"users": [{"username": "rig"}], "root_enc_password": "x"} + self.write_config(config) + ctx = self.from_env() + + arch_config = json.loads(ctx.arch_config_path.read_text()) + for key in ("!users", "!root-password", "root_enc_password", "users"): + self.assertNotIn(key, arch_config) + self.assertEqual(arch_config["auth_config"], {}) + def test_oem_unencrypted_touches_nothing(self): self.write_config(self.base_config(oem=True)) ctx = self.from_env()