diff --git a/bin/omarchy-iso-test b/bin/omarchy-iso-test index 3fadb7d..7858b3a 100755 --- a/bin/omarchy-iso-test +++ b/bin/omarchy-iso-test @@ -614,12 +614,20 @@ drive_configurator() { capture_console "success-installer-02-install-disk" press ret # the single virtio disk - wait_for_screen "installation mode" 60 - capture_console "success-installer-03-install-mode" - if $OEM; then - press down # "OEM install" sits below "Full disk install" on a blank disk + # A blank disk shows the mode picker only when the bundled runtime supports + # OEM mode; otherwise the wizard goes straight to the overwrite confirm. + wait_for_screen "installation mode\|recovery possible" 60 + if ocr_screen | grep -qi "installation mode"; then + capture_console "success-installer-03-install-mode" + if $OEM; then + press down # "OEM install" sits below "Full disk install" on a blank disk + fi + press ret + elif $OEM; then + capture_console "failure-installer-no-oem-choice" + echo "OEM flow requested but this ISO does not offer OEM mode" >&2 + return 1 fi - press ret wait_for_screen "recovery possible" 60 capture_console "success-installer-04-disk-warning-encrypted" diff --git a/configs/airootfs/root/configurator b/configs/airootfs/root/configurator index 03b0883..9678d31 100644 --- a/configs/airootfs/root/configurator +++ b/configs/airootfs/root/configurator @@ -791,7 +791,8 @@ oem_supported() { OEM_SUPPORTED=true if [[ -d $mirror ]]; then pkg=$(ls "$mirror/$OMARCHY_RUNTIME_PACKAGE"-[0-9]*.pkg.tar.zst 2>/dev/null | head -1) - if [[ -z $pkg ]] || ! tar -tf "$pkg" usr/bin/omarchy-oem-setup &>/dev/null; then + if [[ -z $pkg ]] || ! tar -tf "$pkg" usr/bin/omarchy-oem-setup \ + usr/share/omarchy/install/oem/omarchy-oem-setup.service &>/dev/null; then OEM_SUPPORTED=false fi fi diff --git a/configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py b/configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py index 02cf77f..018c559 100644 --- a/configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py +++ b/configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py @@ -65,6 +65,15 @@ class InstallContext: else: raise RuntimeError(f"credentials file missing: {creds_path}") + if oem: + # OEM promises no account exists until first boot. A supplied + # credentials file (a rig handing over its LUKS passphrase) must + # not smuggle in users or a root password. + encryption_password = user_credentials.get("encryption_password") + user_credentials = {"users": []} + if encryption_password: + user_credentials["encryption_password"] = encryption_password + arch_configuration = dict(user_configuration) arch_configuration.pop("omarchy_install", None) state_dir = Path(os.environ.get("OMARCHY_INSTALL_STATE_DIR", "/run/omarchy-install")) diff --git a/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py b/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py index e2613f8..81a17de 100644 --- a/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py +++ b/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py @@ -1208,13 +1208,13 @@ def stage_oem_state(ctx: InstallContext) -> None: def _stage_node_tarball(ctx: InstallContext, oem_dir) -> None: tarballs = sorted(NODE_PACKAGES_DIR.glob("node-v*-linux-x64.tar.gz")) if not tarballs: - if ctx.oem: - raise RuntimeError( - "no bundled Node tarball in /opt/packages — OEM first-boot setup " - "could not finalize the user offline" - ) - info("› no bundled Node tarball to stash for factory reset; skipping") - return + # Hard error on every install, not just OEM: the stash is what lets a + # later factory reset finalize the next owner offline, and an ISO + # build always bundles the tarball — its absence means a broken build. + raise RuntimeError( + f"no bundled Node tarball in {NODE_PACKAGES_DIR} — first-boot setup " + "and factory reset could not finalize a user offline" + ) packages_dir = oem_dir / "packages" packages_dir.mkdir(parents=True, exist_ok=True) @@ -1820,13 +1820,41 @@ def create_factory_snapshot(ctx: InstallContext) -> None: info("› snapshotting @ as @factory (read-only)") subprocess.run( - ["btrfs", "subvolume", "snapshot", "-r", str(root_subvol), str(factory)], + ["btrfs", "subvolume", "snapshot", str(root_subvol), str(factory)], + check=True, + ) + _scrub_factory_snapshot(factory) + subprocess.run( + ["btrfs", "property", "set", "-ts", str(factory), "ro", "true"], check=True, ) finally: subprocess.run(["umount", str(top)], check=False, capture_output=True) +# Provisioning credentials staged for THIS deployment's first boot must not +# survive into the factory image: a reset years later would otherwise hand the +# next owner the original deployment's SSH keys or rejoin its tailnet, and a +# stale OEM LUKS key (dead after the first re-key) has no business lingering. +# The mkinitcpio/cmdline drop-ins go with the keyfile — a reset rebuild would +# otherwise fail on FILES pointing at a scrubbed path. +FACTORY_SCRUB_PATHS = ( + "var/lib/omarchy/oem/authorized_keys", + "var/lib/omarchy/oem/luks-key", + "etc/omarchy/oem.key", + "etc/limine-entry-tool.d/99-omarchy-oem-unlock.conf", + "etc/mkinitcpio.conf.d/99-omarchy-oem-key.conf", + "etc/tailscale/authkey", + "etc/systemd/system/omarchy-tailscale-join.service", + "etc/systemd/system/multi-user.target.wants/omarchy-tailscale-join.service", +) + + +def _scrub_factory_snapshot(factory: Path) -> None: + for rel in FACTORY_SCRUB_PATHS: + (factory / rel).unlink(missing_ok=True) + + def _findmnt_value(path: Path, column: str) -> str | None: res = subprocess.run( ["findmnt", "-no", column, str(path)], diff --git a/test/test_oem_state.py b/test/test_oem_state.py index 69fcd4d..422dcd3 100644 --- a/test/test_oem_state.py +++ b/test/test_oem_state.py @@ -121,6 +121,22 @@ class ContextOemTest(unittest.TestCase): "rig-secret", ) + def test_oem_discards_account_material_from_supplied_credentials(self): + # A rig credentials file must not smuggle in users or a root password. + self.write_config(self.base_config(oem=True)) + self.write_creds({ + "users": [{"username": "backdoor", "enc_password": "x", "sudo": True}], + "root_enc_password": "x", + "encryption_password": "rig-secret", + }) + ctx = self.from_env() + self.assertEqual(ctx.user_credentials["users"], []) + self.assertNotIn("root_enc_password", ctx.user_credentials) + self.assertEqual(ctx.user_credentials["encryption_password"], "rig-secret") + synthesized = json.loads(ctx.creds_path.read_text()) + self.assertEqual(synthesized["users"], []) + self.assertNotIn("root_enc_password", synthesized) + def test_oem_unencrypted_touches_nothing(self): self.write_config(self.base_config(oem=True)) ctx = self.from_env() @@ -194,6 +210,14 @@ class StageOemStateTest(unittest.TestCase): with self.assertRaisesRegex(RuntimeError, "Node tarball"): phases_impl.stage_oem_state(ctx) + def test_missing_node_tarball_fails_normal_installs_too(self): + # The stash is what makes a later factory reset work offline. + for tarball in self.packages.glob("*.tar.gz"): + tarball.unlink() + ctx = make_ctx(self.target, oem=False) + with self.assertRaisesRegex(RuntimeError, "Node tarball"): + phases_impl.stage_oem_state(ctx) + def test_oem_arms_first_boot_setup(self): self.install_runtime_oem_support() ctx = make_ctx(self.target) @@ -357,11 +381,36 @@ class CreateFactorySnapshotTest(unittest.TestCase): top = str(self.state_dir / "factory-top") self.assertIn(["mount", "-o", "subvolid=5", "/dev/mapper/omarchy_root", top], self.calls) self.assertIn( - ["btrfs", "subvolume", "snapshot", "-r", f"{top}/@", f"{top}/@factory"], + ["btrfs", "subvolume", "snapshot", f"{top}/@", f"{top}/@factory"], + self.calls, + ) + self.assertIn( + ["btrfs", "property", "set", "-ts", f"{top}/@factory", "ro", "true"], self.calls, ) self.assertIn(["umount", top], self.calls) + def test_factory_snapshot_scrubs_provisioning_credentials(self): + # Simulate the snapshot carrying deployment credentials; the scrub + # must drop them before the snapshot goes read-only. + top = self.state_dir / "factory-top" + factory = top / "@factory" + secrets = [ + factory / "var/lib/omarchy/oem/authorized_keys", + factory / "etc/tailscale/authkey", + factory / "etc/omarchy/oem.key", + ] + keep = factory / "var/lib/omarchy/oem/groups" + for path in [*secrets, keep]: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("secret") + + phases_impl.create_factory_snapshot(self.ctx()) + + for path in secrets: + self.assertFalse(path.exists(), path) + self.assertTrue(keep.exists()) + def test_non_btrfs_target_skips(self): self.findmnt["FSTYPE"] = "ext4" phases_impl.create_factory_snapshot(self.ctx())