From f66b6fc28ecac5520283ed347a4059bdf75c3f5e Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 3 Aug 2026 12:22:49 -0700 Subject: [PATCH 01/11] Prune unneeded packages from the ISO mirror cache --- builder/build-iso.sh | 77 ++++++++++++++-------------- builder/prune-offline-mirror.sh | 67 ++++++++++++++++++++++++ test/test_offline_mirror_pruning.py | 79 +++++++++++++++++++++++++++++ 3 files changed, 185 insertions(+), 38 deletions(-) create mode 100755 builder/prune-offline-mirror.sh create mode 100644 test/test_offline_mirror_pruning.py diff --git a/builder/build-iso.sh b/builder/build-iso.sh index b52a5b1..ae7719b 100755 --- a/builder/build-iso.sh +++ b/builder/build-iso.sh @@ -201,48 +201,49 @@ if ! download_offline_packages; then download_offline_packages fi -prune_stale_package_versions() { - local dir="$1" - local pkgfile name version cmp - local -a stale=() - declare -A newest_file=() - declare -A newest_version=() +# Resolve the exact filenames chosen by the same synced package databases used +# for the download. Pruning by this transaction (rather than merely keeping the +# newest version of every cached package name) removes packages that have left +# the lists or dependency closure, such as an old Electron major version. +if ! resolved_package_files="$( + pacman --config "/configs/pacman-online-${OMARCHY_MIRROR}.conf" --noconfirm \ + --dbpath /tmp/offlinedb -S --print --print-format '%f' "${all_packages[@]}" +)"; then + echo "ERROR: could not resolve the package files required by the offline mirror" >&2 + exit 1 +fi +mapfile -t required_package_files <<< "$resolved_package_files" - while IFS= read -r pkgfile; do - read -r name version < <(pacman -Qp "$pkgfile" 2>/dev/null) || continue - [[ -n $name && -n $version ]] || continue - - if [[ -z ${newest_version[$name]+x} ]]; then - newest_version[$name]="$version" - newest_file[$name]="$pkgfile" - continue - fi - - cmp=$(vercmp "$version" "${newest_version[$name]}") - if (( cmp > 0 )); then - stale+=("${newest_file[$name]}") - newest_version[$name]="$version" - newest_file[$name]="$pkgfile" - else - stale+=("$pkgfile") - fi - done < <(find "$dir" -maxdepth 1 -type f -name '*.pkg.tar.*' ! -name '*.sig' -print | sort) - - if (( ${#stale[@]} > 0 )); then - echo "Pruning stale package versions from offline mirror:" - for pkgfile in "${stale[@]}"; do - echo " $(basename "$pkgfile")" - rm -f "$pkgfile" "$pkgfile.sig" +# The online transaction intentionally excludes packages built from the local +# checkouts. Add those exact artifacts back to the keep-set after verifying +# that the local build left exactly one file for each selected package name. +if [[ -n ${LOCAL_OMARCHY_BUILD:-} ]]; then + for local_package_name in \ + "$OMARCHY_RUNTIME_PACKAGE" "$OMARCHY_SETTINGS_PACKAGE" "$OMARCHY_NVIM_PACKAGE"; do + local_package_file="" + for candidate in "$offline_mirror_dir/$local_package_name-"*.pkg.tar.*; do + [[ -f $candidate && $candidate != *.sig ]] || continue + read -r candidate_name _ < <(pacman -Qp "$candidate" 2>/dev/null) || continue + [[ $candidate_name == "$local_package_name" ]] || continue + if [[ -n $local_package_file ]]; then + echo "ERROR: multiple local builds found for $local_package_name" >&2 + exit 1 + fi + local_package_file="${candidate##*/}" done - fi -} + if [[ -z $local_package_file ]]; then + echo "ERROR: local build not found for $local_package_name" >&2 + exit 1 + fi + required_package_files+=("$local_package_file") + done +fi + +printf '%s\n' "${required_package_files[@]}" | + bash /builder/prune-offline-mirror.sh "$offline_mirror_dir" # Rebuild the offline repo db from scratch so size/checksum/depends entries -# always reflect the current package files. The persistent cache can contain -# multiple versions of the same package; repo-add keeps the last one it sees, -# which is lexical glob order, not version order (e.g. pkgrel -9 can override -# -15). Prune to one newest version per package before indexing. -prune_stale_package_versions "$offline_mirror_dir" +# always reflect only the package files selected for this build. rm -f "$offline_mirror_dir"/offline.db* "$offline_mirror_dir"/offline.files* repo-add "$offline_mirror_dir/offline.db.tar.gz" "$offline_mirror_dir/"*.pkg.tar.zst diff --git a/builder/prune-offline-mirror.sh b/builder/prune-offline-mirror.sh new file mode 100755 index 0000000..ba43119 --- /dev/null +++ b/builder/prune-offline-mirror.sh @@ -0,0 +1,67 @@ +#!/bin/bash + +set -euo pipefail + +offline_mirror_dir="${1:-}" +if [[ -z $offline_mirror_dir ]]; then + echo "Usage: prune-offline-mirror.sh " >&2 + exit 1 +fi +if [[ ! -d $offline_mirror_dir ]]; then + echo "ERROR: offline mirror directory not found: $offline_mirror_dir" >&2 + exit 1 +fi + +# Read the exact package filenames selected by pacman on stdin. Validate the +# complete selection before deleting anything: an empty/partial resolution must +# never turn a warm-cache build into a broken mirror. +declare -A required=() +while IFS= read -r filename; do + [[ -n $filename ]] || continue + if [[ $filename == */* || $filename != *.pkg.tar.* || $filename == *.sig ]]; then + echo "ERROR: invalid required package filename: $filename" >&2 + exit 1 + fi + required["$filename"]=1 +done + +if (( ${#required[@]} == 0 )); then + echo "ERROR: refusing to prune the offline mirror with an empty package selection" >&2 + exit 1 +fi + +missing=() +for filename in "${!required[@]}"; do + [[ -f "$offline_mirror_dir/$filename" ]] || missing+=("$filename") +done +if (( ${#missing[@]} > 0 )); then + echo "ERROR: refusing to prune; selected packages are missing from the mirror:" >&2 + printf ' %s\n' "${missing[@]}" >&2 + exit 1 +fi + +stale=() +while IFS= read -r -d '' package_file; do + filename="${package_file##*/}" + [[ -n ${required[$filename]+x} ]] || stale+=("$package_file") +done < <( + find "$offline_mirror_dir" -maxdepth 1 -type f \ + -name '*.pkg.tar.*' ! -name '*.sig' -print0 +) + +if (( ${#stale[@]} > 0 )); then + echo "Pruning package archives not selected for the offline mirror:" + for package_file in "${stale[@]}"; do + echo " $(basename "$package_file")" + rm -f -- "$package_file" "$package_file.sig" + done +fi + +# A failed or interrupted download can leave a signature without its archive. +# It cannot be indexed and only wastes space in the ISO. +while IFS= read -r -d '' signature_file; do + [[ -f ${signature_file%.sig} ]] || rm -f -- "$signature_file" +done < <( + find "$offline_mirror_dir" -maxdepth 1 -type f \ + -name '*.pkg.tar.*.sig' -print0 +) diff --git a/test/test_offline_mirror_pruning.py b/test/test_offline_mirror_pruning.py new file mode 100644 index 0000000..88ed468 --- /dev/null +++ b/test/test_offline_mirror_pruning.py @@ -0,0 +1,79 @@ +#!/usr/bin/python + +import subprocess +import tempfile +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +PRUNER = ROOT / "builder/prune-offline-mirror.sh" + + +class OfflineMirrorPruningTest(unittest.TestCase): + def run_pruner(self, mirror: Path, required: list[str]): + return subprocess.run( + ["bash", str(PRUNER), str(mirror)], + input="".join(f"{filename}\n" for filename in required), + capture_output=True, + text=True, + ) + + def add_file(self, mirror: Path, filename: str): + (mirror / filename).write_bytes(b"package") + + def test_keeps_only_exactly_selected_package_archives(self): + with tempfile.TemporaryDirectory() as directory: + mirror = Path(directory) + keep = "keep-2.0-1-x86_64.pkg.tar.zst" + other = "other-1.0-1-any.pkg.tar.zst" + stale_version = "keep-1.0-1-x86_64.pkg.tar.zst" + removed_package = "removed-1.0-1-x86_64.pkg.tar.zst" + orphan_signature = "interrupted-1.0-1-x86_64.pkg.tar.zst.sig" + + for filename in (keep, other, stale_version, removed_package): + self.add_file(mirror, filename) + for filename in (keep, stale_version, removed_package): + self.add_file(mirror, f"{filename}.sig") + self.add_file(mirror, orphan_signature) + self.add_file(mirror, "offline.db.tar.gz") + + result = self.run_pruner(mirror, [keep, other]) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual( + {path.name for path in mirror.iterdir()}, + {keep, f"{keep}.sig", other, "offline.db.tar.gz"}, + ) + self.assertIn(stale_version, result.stdout) + self.assertIn(removed_package, result.stdout) + + def test_missing_selection_aborts_before_deleting_anything(self): + with tempfile.TemporaryDirectory() as directory: + mirror = Path(directory) + cached = "cached-1.0-1-x86_64.pkg.tar.zst" + self.add_file(mirror, cached) + + result = self.run_pruner( + mirror, ["missing-1.0-1-x86_64.pkg.tar.zst"] + ) + + self.assertNotEqual(result.returncode, 0) + self.assertTrue((mirror / cached).exists()) + self.assertIn("selected packages are missing", result.stderr) + + def test_empty_selection_aborts_before_deleting_anything(self): + with tempfile.TemporaryDirectory() as directory: + mirror = Path(directory) + cached = "cached-1.0-1-x86_64.pkg.tar.zst" + self.add_file(mirror, cached) + + result = self.run_pruner(mirror, []) + + self.assertNotEqual(result.returncode, 0) + self.assertTrue((mirror / cached).exists()) + self.assertIn("empty package selection", result.stderr) + + +if __name__ == "__main__": + unittest.main() From f5aa88b06cd3476a26c9939e63e8806a9e210305 Mon Sep 17 00:00:00 2001 From: Jeffrey Guenther Date: Tue, 28 Jul 2026 12:27:06 -0700 Subject: [PATCH 02/11] Plan autoinstall against the quattro install pipeline PR #72 targeted the pre-quattro tree and can't be rebased -- the bash seams it hooked into are gone. This plans the same feature against the configurator/dashboard/orchestrator split, and drops the three parts of that PR that were flagged as questionable. Claude-Session: https://claude.ai/code/session_01Sy3HeoBqb13ahLuaiP2UkM --- plans/autoinstall.md | 172 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 plans/autoinstall.md diff --git a/plans/autoinstall.md b/plans/autoinstall.md new file mode 100644 index 0000000..18700ad --- /dev/null +++ b/plans/autoinstall.md @@ -0,0 +1,172 @@ +# Autoinstall Plan + +## Goal + +Let the standard Omarchy ISO install itself with no keyboard present, driven by config files handed to the VM on a second drive. This makes Omarchy usable as a Packer/Proxmox base image for disposable dev environments: create VM, boot, walk away, SSH in. + +## Product Requirements + +- No ISO rebuild. The shipped ISO autoinstalls when config is present and runs the configurator when it is not. +- No new menu entry, no new boot parameter, no separate ISO flavor. +- Config format is exactly what the configurator already writes — no second schema to maintain. +- The installed machine is reachable over SSH with a key supplied at install time. +- Interactive installs behave identically to today. + +--- + +## Chosen Architecture + +### Trigger: config presence, not a mode flag + +The installer looks for a drive labeled `cidata` at startup. If it is there and carries the configurator's output files, those files are copied into `/root` and `./configurator` is skipped — the rest of the install runs the ordinary path against ordinary inputs. If it is not there, nothing changes. + +This is Ryan Hughes' suggestion from PR #72 and it is what keeps the diff small: the install pipeline can be rewritten freely as long as it keeps reading its five files out of `/root`, and autoinstall follows along for free. + +`cidata` is the cloud-init `NoCloud` label, so Proxmox, libvirt, and Packer all already know how to attach one. + +### What the ISO already does for us + +Two things that would otherwise need building are already true on a stock quattro install, per `manifests/fresh-4-semantic.json`: + +- `sshd.service` is enabled. +- `NetworkManager.service` is enabled and active, with DHCP by default. + +So remote access needs only an `authorized_keys` file. No `systemd-networkd` unit (it would contend with NetworkManager), no `systemctl enable sshd`, and specifically no loosening of `PasswordAuthentication` — key auth is the only thing autoinstall adds. + +### Non-interactivity belongs to the dashboard + +`omarchy-install-dashboard` owns every prompt that survives to the end of an install: the reboot confirm and, on failure, the action menu. Both are `gum` calls reading from the TTY, and both hang forever on a headless VM. Autoinstall therefore sets one env var that the dashboard reads, rather than patching prompts out of scripts after the fact — which is what PR #72 did to `finished.sh`, and what everyone correctly disliked. + +--- + +## Non-Goals + +- No config generator, validator, or schema doc. The configurator is the source of truth for the format; users copy its output. +- No secret management. Whoever builds the `cidata` drive owns the password hash and the public key on it. +- No PXE, no network-fetched config, no cloud-init compatibility beyond reusing the drive label. +- No changes to disk selection, encryption, or any install phase behavior. + +--- + +## Repo Implementation Plan + +## 1) Detect the cidata drive and skip the configurator + +### File +- `configs/airootfs/root/.automated_script.sh` + +### Changes +- Add a `load_cidata` function above the existing `cd /root` (`:93`): + - Look for `/dev/disk/by-label/cidata` then `/dev/disk/by-label/CIDATA`; return non-zero if neither exists. + - Mount read-only on a temp mountpoint; return non-zero if the mount fails. + - Require both `user_configuration.json` and `user_credentials.json` on the drive — this is the same pair `InstallContext.from_env` treats as mandatory. Missing either means the drive is not an autoinstall drive; unmount and return non-zero. + - Copy the required pair plus, when present, `user_full_name.txt`, `user_email_address.txt`, `user_encrypt_installation.txt`, and `ssh.json` into `/root`. + - Unmount before returning. Leave nothing mounted for the install to trip over. +- Replace the bare `./configurator` call (`:94`) with: run `load_cidata`; on success export `OMARCHY_UI_INTERACTIVE=no`, on failure run `./configurator`. +- Pass `--ssh-keys-file /root/ssh.json` to `omarchy-iso-install` in the dashboard invocation (`:101`–`:110`), alongside the existing five flags. Always pass it; the orchestrator no-ops when the file is absent, which keeps the interactive path on the identical command line. + +### Notes +- The three text files are optional by construction: `_read_text` in `context.py` returns `""` for a missing path and `encrypt` falls back to false. Do not add stricter checks here than the orchestrator itself applies. +- `load_cidata` must not run before `warm_offline_mirror` is backgrounded — the mirror warm should keep its head start. + +### Acceptance criteria +- ISO with no `cidata` drive attached: configurator runs, byte-identical UX to today. +- ISO with a `cidata` drive carrying the five files: configurator never appears; install starts within seconds of boot. +- ISO with a drive labeled `cidata` that is empty or carries only one of the two required files: falls back to the configurator rather than failing the boot. +- Nothing remains mounted from `/dev/disk/by-label/cidata` once the install begins. + +--- + +## 2) Non-interactive dashboard + +### File +- `configs/airootfs/usr/local/bin/omarchy-install-dashboard` + +### Changes +- Read `OMARCHY_UI_INTERACTIVE` once near the other env reads at the top. +- In `reboot_prompt()` (`:474`): when non-interactive, return 0 immediately instead of calling `gum confirm`. The existing `OMARCHY_UI_AUTO_REBOOT=no` check at `:737` stays in front of the actual `reboot`, so a debug run can set both and stop on the finish screen without a keyboard. +- In `failure_menu()` (`:597`): treat non-interactive as equivalent to the existing `OMARCHY_UI_FAILURE_ACTION=exit` early return (`:600`), so a failed autoinstall exits with the installer's status instead of blocking on `gum choose` forever. The failure screen and log tail still render, and the serial console still captures them. + +### Why here and not in the caller +The dashboard is the only process that owns the visible UI. Suppressing its prompts anywhere else means editing scripts it invokes — the `finished.sh` `sed` from PR #72 — which breaks the moment those scripts move. + +### Acceptance criteria +- Autoinstall run reaches the finish screen and reboots with no input. +- Autoinstall run whose install fails renders the failure screen, exits non-zero, and does not hang. +- Interactive run still prompts for reboot and still offers the failure menu. +- `OMARCHY_UI_INTERACTIVE=no` plus `OMARCHY_UI_AUTO_REBOOT=no` stops at the finish screen without prompting. + +--- + +## 3) SSH keys as an orchestrator phase + +### Files +- `configs/airootfs/usr/local/bin/omarchy-iso-install` +- `configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py` +- `configs/airootfs/usr/share/omarchy-iso/orchestrator/main.py` +- `configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py` + +### Changes +- `omarchy-iso-install`: add `--ssh-keys-file` to the arg loop, exporting `OMARCHY_INSTALL_SSH_KEYS_FILE`. Same shape as the five existing flags, ahead of the `unknown arg` catch-all (`:19`). +- `context.py`: add an `ssh_keys_path: Path | None` field populated from that variable in `from_env`, `None` when unset or when the file does not exist. +- `phases_impl.py`: add `configure_remote_access(ctx)`: + - Return immediately when `ctx.ssh_keys_path` is `None`. + - Parse the file as a JSON array of public key strings; write them one per line to `/home//.ssh/authorized_keys`. + - `.ssh` at `0700`, `authorized_keys` at `0600`, both owned by the user via `arch-chroot chown -R :`. Do not hardcode uid 1000 as PR #72 did — ask the target for it. + - A malformed or empty `ssh.json` must fail the phase loudly. A machine that installs "successfully" and is then unreachable is worse than one that stops with an error on screen. +- `main.py`: register `("Configuring remote access", configure_remote_access)` in `build_phases` after `configure_login` (`:52`) and before `validate_boot`. + +### Model to follow +`configure_login` (`phases_impl.py:1251`) — direct writes against `ctx.target`, `arch-chroot` reserved for ownership and `systemctl`. Match its structure. + +### Acceptance criteria +- Install with `ssh.json`: `ssh @` works with the corresponding private key on first boot, no password. +- Install without `ssh.json`: phase runs, does nothing, install unaffected; `~/.ssh` is not created. +- `authorized_keys` is `0600` and owned by the install user, not root. +- Password SSH auth is left at the distro default — autoinstall does not enable it. +- Interactive installs are unaffected: the flag is passed but the file never exists. + +--- + +## 4) Documentation + +### File +- `README.md` + +### Changes +- New "Autoinstall" section covering: what the `cidata` drive is, the file table, how to produce the drive, and a Proxmox `qm create` example with disk-first boot order so the empty disk falls through to the ISO on the first boot and boots the installed system afterward. +- Show `openssl passwd -6` for the credential hash and the `ssh.json` array format. +- State plainly that the config files are the configurator's own output — the documented way to get a starting `user_configuration.json` is to run one interactive install and copy what it wrote. +- Do not repeat PR #72's networking claims. Say that sshd and NetworkManager come enabled on a stock install and that autoinstall only adds the key. + +--- + +## 5) Validation + +### Manual matrix +1. Build the ISO from this branch. +2. Interactive control run: boot with no `cidata` drive, install normally, confirm nothing changed. +3. Autoinstall run on Proxmox: attach ISO + `cidata` drive, boot, no input; confirm the configurator never renders, the install completes, the machine reboots on its own, and SSH with the key works against the booted system. +4. Encrypted autoinstall: `user_encrypt_installation.txt` set true; confirm the LUKS prompt appears on boot (this is the one autoinstall case that still needs a human at first boot — document it). +5. Full Packer cycle: build, destroy, rebuild from the same `cidata` drive; confirm reproducibility. + +### Negative tests +- Drive labeled `cidata` with no files → configurator runs. +- Drive with `user_configuration.json` only → configurator runs. +- Malformed `ssh.json` → install stops in the remote-access phase with a visible error, does not reboot into an unreachable machine. +- Install failure under autoinstall (e.g. a disk target that does not exist) → failure screen renders, process exits non-zero, no hang. + +### Regression surface +The interactive path shares every line of this change except the `load_cidata` branch, so run the standard acceptance harness (`./bin/omarchy-iso-test`) on the built ISO before proposing the PR. + +--- + +## Relationship to PR #72 + +PR #72 targeted the pre-`quattro` tree and cannot be rebased: `use_omarchy_helpers`, `run_configurator`, `install_arch`, `install_omarchy`, and `chroot_bash` no longer exist. This plan reimplements the same feature against the current tree and drops three things that PR deliberately flagged as questionable: + +- The `finished.sh` `sed` — replaced by section 2. The dashboard renders the reboot prompt now. +- The `systemd-networkd` DHCP unit — unnecessary; NetworkManager is already enabled and would contend with it. +- The `PasswordAuthentication yes` rewrite — unnecessary and a security downgrade. + +`load_cidata` is the one piece that carries over close to intact. From a4ec97d174e7121a483a3325d67ef5c5ab845b8b Mon Sep 17 00:00:00 2001 From: Jeffrey Guenther Date: Tue, 28 Jul 2026 12:32:44 -0700 Subject: [PATCH 03/11] Autoinstall from a cidata drive instead of the configurator Look for a drive labeled cidata at startup. If it carries the configurator's own output files, copy them into /root and skip the wizard -- everything downstream then runs the ordinary path against ordinary inputs. No drive, no change. The required pair matches what the orchestrator treats as mandatory, so a drive with less than that falls back to the wizard rather than failing the boot. Claude-Session: https://claude.ai/code/session_01Sy3HeoBqb13ahLuaiP2UkM --- configs/airootfs/root/.automated_script.sh | 42 ++++++++++++++++++- .../usr/local/bin/omarchy-iso-install | 1 + 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/configs/airootfs/root/.automated_script.sh b/configs/airootfs/root/.automated_script.sh index 28666c4..b58f96b 100644 --- a/configs/airootfs/root/.automated_script.sh +++ b/configs/airootfs/root/.automated_script.sh @@ -90,8 +90,45 @@ warm_offline_mirror & warm_pid=$! trap 'kill "$warm_pid" 2>/dev/null' EXIT +# Autoinstall: a drive labeled cidata (the cloud-init NoCloud label, so Proxmox, +# libvirt and Packer all know how to attach one) carrying the configurator's own +# output stands in for the wizard. Copy those files into /root and everything +# downstream runs the ordinary path against ordinary inputs. +# +# The required pair is the same one the orchestrator treats as mandatory; the +# rest are optional there too, so don't be stricter here. Anything less than the +# pair means this isn't an autoinstall drive, and we fall back to the wizard. +load_cidata() { + local device="" label mnt=/run/cidata + + for label in cidata CIDATA; do + [[ -e /dev/disk/by-label/$label ]] && { device=/dev/disk/by-label/$label; break; } + done + [[ -n $device ]] || return 1 + + mkdir -p "$mnt" + mount -o ro "$device" "$mnt" || return 1 + + if [[ -f $mnt/user_configuration.json && -f $mnt/user_credentials.json ]]; then + cp "$mnt"/user_configuration.json "$mnt"/user_credentials.json /root/ + for file in user_full_name.txt user_email_address.txt user_encrypt_installation.txt ssh.json; do + [[ -f $mnt/$file ]] && cp "$mnt/$file" /root/ + done + umount "$mnt" + return 0 + fi + + umount "$mnt" + return 1 +} + cd /root -./configurator +if load_cidata; then + echo "Autoinstall configuration found on cidata drive; skipping the configurator." + export OMARCHY_UI_INTERACTIVE=no +else + ./configurator +fi # The foreground dashboard is now the sole visible install UI owner. It starts # the actual installer as a non-interactive child, logs child output, waits for @@ -107,4 +144,5 @@ rm -f /run/omarchy-install/state.json --creds /root/user_credentials.json \ --full-name-file /root/user_full_name.txt \ --email-file /root/user_email_address.txt \ - --encrypt-file /root/user_encrypt_installation.txt + --encrypt-file /root/user_encrypt_installation.txt \ + --ssh-keys-file /root/ssh.json diff --git a/configs/airootfs/usr/local/bin/omarchy-iso-install b/configs/airootfs/usr/local/bin/omarchy-iso-install index 3b1de13..0380a1b 100755 --- a/configs/airootfs/usr/local/bin/omarchy-iso-install +++ b/configs/airootfs/usr/local/bin/omarchy-iso-install @@ -16,6 +16,7 @@ while [[ $# -gt 0 ]]; do --full-name-file) export OMARCHY_INSTALL_FULL_NAME_FILE="$2"; shift 2 ;; --email-file) export OMARCHY_INSTALL_EMAIL_FILE="$2"; shift 2 ;; --encrypt-file) export OMARCHY_INSTALL_ENCRYPT_FILE="$2"; shift 2 ;; + --ssh-keys-file) export OMARCHY_INSTALL_SSH_KEYS_FILE="$2"; shift 2 ;; *) echo "omarchy-iso-install: unknown arg $1" >&2; exit 2 ;; esac done From b323a4cf08c41c5de2d545ece7716eed0ce3b9f3 Mon Sep 17 00:00:00 2001 From: Jeffrey Guenther Date: Tue, 28 Jul 2026 12:32:44 -0700 Subject: [PATCH 04/11] Skip the dashboard's prompts when nobody is at the keyboard The dashboard owns the two prompts that outlive an install -- the reboot confirm and the failure menu -- and both read from the TTY, so both hang forever on a headless VM. Gate them on OMARCHY_UI_INTERACTIVE here, in the one process that owns the visible UI, rather than patching prompts out of the scripts they live in. The existing OMARCHY_UI_AUTO_REBOOT check still guards the reboot itself, so setting both stops on the finish screen without a keyboard. Claude-Session: https://claude.ai/code/session_01Sy3HeoBqb13ahLuaiP2UkM --- .../usr/local/bin/omarchy-install-dashboard | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/configs/airootfs/usr/local/bin/omarchy-install-dashboard b/configs/airootfs/usr/local/bin/omarchy-install-dashboard index 6f6c474..9dc2672 100755 --- a/configs/airootfs/usr/local/bin/omarchy-install-dashboard +++ b/configs/airootfs/usr/local/bin/omarchy-install-dashboard @@ -23,6 +23,12 @@ INSTALL_CMD=("$@") TTY_PATH="${OMARCHY_DASHBOARD_TTY:-/dev/tty}" LOGO_PATH="${OMARCHY_PATH:-/usr/share/omarchy}/logo.txt" + +# Autoinstall has no one at the keyboard, and every prompt this dashboard owns +# reads from the TTY, so each would hang forever. Suppress them here rather than +# in the scripts they live in: this process is the only owner of the visible UI. +interactive() { [[ ${OMARCHY_UI_INTERACTIVE:-} != "no" ]]; } + CSI=$'\033[' RESET="${CSI}0m" DIM="${CSI}2m" @@ -474,6 +480,10 @@ render_finish() { reboot_prompt() { local prompt_pad + # Nobody to confirm; the OMARCHY_UI_AUTO_REBOOT check at the call site still + # applies, so setting both stops on the finish screen without a keyboard. + interactive || return 0 + # Position the cursor one blank line below the title so the button sits right there. printf '%s%d;1H%s' "$CSI" "$((LOGO_HEIGHT + 5))" "$CLEAR_LINE" >"$TTY_PATH" @@ -598,6 +608,9 @@ failure_menu() { local choice uploader [[ ${OMARCHY_UI_FAILURE_ACTION:-} == "exit" ]] && return 0 + # The failure screen and log tail have already rendered; exit with the + # installer's status instead of blocking on a menu nobody can answer. + interactive || return 0 while true; do uploader="" From 5f601f2c545b1a03c7120ee606a7907eabc4fe84 Mon Sep 17 00:00:00 2001 From: Jeffrey Guenther Date: Tue, 28 Jul 2026 13:51:32 -0700 Subject: [PATCH 05/11] Make the installed machine reachable over SSH An autoinstall drive can carry an ssh.json of public keys. Install them for the user, enable sshd, and open port 22. All three are needed: Omarchy ships openssh with the service disabled, and install/config/firewall.sh opens LocalSend and docker DNS but not 22, so keys alone leave the machine pingable with SSH timing out. The ufw call exits non-zero from a chroot -- it cannot reach netfilter -- but writes the rule to user.rules before failing, and that file is what ufw.service loads on first boot. Check the file rather than the exit status, since the file is the thing that has to be right. Unusable key files fail the phase. A machine that installs "successfully" and then can't be logged into is worse than one that stops with the reason on screen. Claude-Session: https://claude.ai/code/session_01Sy3HeoBqb13ahLuaiP2UkM --- .../share/omarchy-iso/orchestrator/context.py | 12 +++ .../share/omarchy-iso/orchestrator/main.py | 2 + .../omarchy-iso/orchestrator/phases_impl.py | 89 +++++++++++++++++++ 3 files changed, 103 insertions(+) diff --git a/configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py b/configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py index 1d2e704..34e8de9 100644 --- a/configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py +++ b/configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py @@ -18,6 +18,7 @@ class InstallContext: full_name: str email: str encrypt: bool + ssh_keys_path: Path | None user_configuration: dict user_credentials: dict @@ -59,6 +60,7 @@ class InstallContext: full_name=_read_text(os.environ.get("OMARCHY_INSTALL_FULL_NAME_FILE")), email=_read_text(os.environ.get("OMARCHY_INSTALL_EMAIL_FILE")), encrypt=_read_text(os.environ.get("OMARCHY_INSTALL_ENCRYPT_FILE")).lower() in ("true", "yes", "1"), + ssh_keys_path=_optional_path(os.environ.get("OMARCHY_INSTALL_SSH_KEYS_FILE")), user_configuration=user_configuration, user_credentials=json.loads(creds_path.read_text()), arch_config_path=arch_config_path, @@ -103,6 +105,16 @@ def _default_omarchy_install(user_configuration: dict) -> dict[str, Any]: } +def _optional_path(path: str | None) -> Path | None: + """A path the caller always passes but that need not exist. The live ISO + passes --ssh-keys-file on every install; only autoinstall drives put a file + there.""" + if not path: + return None + p = Path(path) + return p if p.exists() else None + + def _read_text(path: str | None) -> str: if not path: return "" diff --git a/configs/airootfs/usr/share/omarchy-iso/orchestrator/main.py b/configs/airootfs/usr/share/omarchy-iso/orchestrator/main.py index 62f4c88..3d35ef7 100644 --- a/configs/airootfs/usr/share/omarchy-iso/orchestrator/main.py +++ b/configs/airootfs/usr/share/omarchy-iso/orchestrator/main.py @@ -38,6 +38,7 @@ def build_phases(ctx: InstallContext): run_chroot_finalizer, configure_dns_resolver, configure_login, + configure_ssh_access, validate_boot, ) @@ -50,6 +51,7 @@ def build_phases(ctx: InstallContext): ("Finalizing Limine boot", finalize_limine_boot), ("Finalizing user", run_chroot_finalizer), ("Configuring login", configure_login), + ("Configuring SSH access", configure_ssh_access), ("Configuring DNS resolver", configure_dns_resolver), ("Validating boot setup", validate_boot), ] 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 468636a..ba0a639 100644 --- a/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py +++ b/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py @@ -15,12 +15,14 @@ Phase ordering (full-disk and protected/pre-mounted): finalize_limine_boot → final Limine config/UKI build after hardware drop-ins run_chroot_finalizer → arch-chroot -u user omarchy-finalize-user configure_login → sddm state + encrypted-install autologin + configure_ssh_access → authorized_keys for autoinstall; no-op otherwise validate_boot → assert UKI / limine.conf / kernel cmdline are sane """ from __future__ import annotations import hashlib +import json import os import re import shutil @@ -1304,6 +1306,93 @@ def configure_login(ctx: InstallContext) -> None: ) +# ───────────────────────────────────────────────────────────────────────────── +# configure_ssh_access: make the installed machine reachable over SSH with the +# keys an autoinstall drive supplied. A stock Omarchy install ships openssh but +# leaves sshd disabled, and its firewall.sh opens only LocalSend and docker DNS, +# so all three pieces -- keys, service, firewall -- have to be done here. +# ───────────────────────────────────────────────────────────────────────────── + +def configure_ssh_access(ctx: InstallContext) -> None: + if ctx.ssh_keys_path is None: + return + + keys = _authorized_keys(ctx.ssh_keys_path) + info(f"› installing {len(keys)} SSH key(s) for {ctx.username}") + + ssh_dir = ctx.target / "home" / ctx.username / ".ssh" + ssh_dir.mkdir(parents=True, exist_ok=True) + ssh_dir.chmod(0o700) + + authorized_keys = ssh_dir / "authorized_keys" + authorized_keys.write_text("".join(f"{key}\n" for key in keys)) + authorized_keys.chmod(0o600) + + # Ask the target for the uid rather than assuming the first user is 1000. + # Uncaptured so a failure shows up in the install log, and checked because + # a root-owned authorized_keys is one sshd refuses to read. + subprocess.run( + ["arch-chroot", str(ctx.target), "chown", "-R", + f"{ctx.username}:{ctx.username}", f"/home/{ctx.username}/.ssh"], + check=True, + ) + + info("› enabling sshd") + subprocess.run( + ["arch-chroot", str(ctx.target), "systemctl", "enable", "sshd.service"], + check=True, + ) + + _allow_ssh_through_firewall(ctx) + + +def _allow_ssh_through_firewall(ctx: InstallContext) -> None: + """Open port 22 in the target's ufw. + + Omarchy's install/config/firewall.sh opens LocalSend and docker DNS and + nothing else, and ufw runs default-deny incoming, so an enabled sshd is + still unreachable -- connections time out rather than being refused. + + ufw cannot reach netfilter from inside the chroot and exits non-zero saying + so, but it writes the rule to user.rules first, and that file is what + ufw.service loads on first boot. So the exit status is the wrong thing to + check here; the rule landing in the file is the thing that matters. + """ + if not (ctx.target / "usr" / "bin" / "ufw").exists(): + info("› no ufw in target, leaving firewall alone") + return + + info("› allowing SSH through ufw") + subprocess.run(["arch-chroot", str(ctx.target), "ufw", "allow", "ssh"], check=False) + + rules = ctx.target / "etc" / "ufw" / "user.rules" + text = rules.read_text() if rules.exists() else "" + if "--dport 22 -j ACCEPT" not in text: + raise RuntimeError(f"ufw did not record an allow rule for port 22 in {rules}") + + +def _authorized_keys(path: Path) -> list[str]: + """Parse the autoinstall ssh.json: a JSON array of public key strings. + + Raise rather than skip on anything unusable. An install that "succeeds" + into a machine nobody can log into is worse than one that stops with the + reason on screen. + """ + try: + keys = json.loads(path.read_text()) + except (OSError, json.JSONDecodeError) as exc: + raise RuntimeError(f"{path} is not readable JSON: {exc}") from exc + + if not isinstance(keys, list) or not all(isinstance(key, str) for key in keys): + raise RuntimeError(f"{path} must be a JSON array of public key strings") + + keys = [key.strip() for key in keys if key.strip()] + if not keys: + raise RuntimeError(f"{path} contains no SSH keys") + + return keys + + # ───────────────────────────────────────────────────────────────────────────── # validate_boot: hard checks before reboot. If the install ran but produced a # boot config or UKI that can't actually boot, halt here rather than surprise From 7e782bb7cf4b3ca3698960eb817e99aa1f376b20 Mon Sep 17 00:00:00 2001 From: Jeffrey Guenther Date: Tue, 28 Jul 2026 13:51:32 -0700 Subject: [PATCH 06/11] Document autoinstall Claude-Session: https://claude.ai/code/session_01Sy3HeoBqb13ahLuaiP2UkM --- README.md | 57 ++++++++++++++++++++++++++++++++++++++++++++ plans/autoinstall.md | 22 +++++++++++------ 2 files changed, 72 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index c80685e..b354747 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,63 @@ Despite the local folder name, the first argument is the Omarchy source checkout Use `--dev` or `--rc` to build against those package channels. Both `--dev` and `--edge` select the dev packages from the edge mirror. +## Autoinstall + +The shipped ISO installs itself with no keyboard when it finds its configuration on a second drive. Attach a drive labeled `cidata` alongside the ISO and the installer copies the config off it and skips the configurator; with no such drive, nothing changes and the wizard runs as usual. No rebuild, no extra boot entry. + +`cidata` is the cloud-init `NoCloud` label, so Proxmox, libvirt, and Packer already know how to attach one. + +### Configuration files + +These are the configurator's own output files, so the way to get a starting set is to run one interactive install and copy what it wrote into `/root`. + +| File | Required | Purpose | +|------|----------|---------| +| `user_configuration.json` | Yes | archinstall config: disk, hostname, timezone, keyboard | +| `user_credentials.json` | Yes | Username and password hash | +| `user_full_name.txt` | No | Git full name | +| `user_email_address.txt` | No | Git email | +| `user_encrypt_installation.txt` | No | `true` to encrypt the install; defaults to false | +| `ssh.json` | No | JSON array of SSH public keys | + +Both required files must be present or the installer falls back to the configurator. Generate the password hash for `user_credentials.json` with `openssl passwd -6 "yourpassword"`. + +`ssh.json` is a JSON array of public keys: + +```json +["ssh-ed25519 AAAA... you@host"] +``` + +When `ssh.json` is present, autoinstall writes the keys to the user's `~/.ssh/authorized_keys`, enables `sshd`, and adds a `ufw allow ssh` rule — a stock Omarchy install ships openssh with the service disabled and its firewall opens neither port 22 nor anything else beyond LocalSend. Networking needs nothing extra; NetworkManager is already enabled with DHCP. Password SSH authentication is left at the distro default. An unparseable or empty `ssh.json` fails the install rather than producing a machine nobody can reach. + +### Building the drive + +```bash +mkdir cidata +cp user_configuration.json user_credentials.json ssh.json cidata/ +genisoimage -output cidata.iso -volid cidata -joliet -rock cidata/ +``` + +### Proxmox example + +```bash +qm create 101 --name my-omarchy \ + --bios ovmf --machine q35 --cpu host --cores 4 --memory 8192 \ + --ostype l26 --scsihw virtio-scsi-single \ + --efidisk0 local-lvm:0,efitype=4m,pre-enrolled-keys=0 \ + --scsi0 local-lvm:40,discard=on,iothread=1 \ + --net0 virtio,bridge=vmbr0 --vga virtio --serial0 socket \ + --ide2 local:iso/omarchy.iso,media=cdrom \ + --ide3 local:iso/cidata.iso,media=cdrom \ + --boot order='scsi0;ide2' + +qm start 101 +``` + +Boot order is disk first: the empty disk falls through to the ISO on the first boot, and the installed system boots from disk afterwards. The machine reboots into Omarchy on its own when the install finishes. + +Encrypted autoinstalls are not fully unattended — the LUKS passphrase prompt still needs someone at the first boot. + ## Testing the ISO Run `./bin/omarchy-iso-boot [release/omarchy.iso]`. diff --git a/plans/autoinstall.md b/plans/autoinstall.md index 18700ad..023621d 100644 --- a/plans/autoinstall.md +++ b/plans/autoinstall.md @@ -26,12 +26,17 @@ This is Ryan Hughes' suggestion from PR #72 and it is what keeps the diff small: ### What the ISO already does for us -Two things that would otherwise need building are already true on a stock quattro install, per `manifests/fresh-4-semantic.json`: +One thing that would otherwise need building is already true on a stock install: `NetworkManager.service` is enabled and active with DHCP by default, so autoinstall needs no `systemd-networkd` unit — one would contend with NetworkManager. -- `sshd.service` is enabled. -- `NetworkManager.service` is enabled and active, with DHCP by default. +Remote access needs three things, all verified against a real install rather than inferred: -So remote access needs only an `authorized_keys` file. No `systemd-networkd` unit (it would contend with NetworkManager), no `systemctl enable sshd`, and specifically no loosening of `PasswordAuthentication` — key auth is the only thing autoinstall adds. +- `authorized_keys` for the user. +- `systemctl enable sshd.service`. Omarchy ships `openssh` but leaves the service disabled. +- `ufw allow ssh`. `install/config/firewall.sh` opens LocalSend (53317) and docker DNS and nothing else, and ufw runs default-deny incoming. + +Still no loosening of `PasswordAuthentication` — key auth only. + +Do not take `manifests/fresh-4-semantic.json` as the authority on any of this. It claims `sshd.service: enabled` and carries an `allow tcp 22` ufw rule; a real install of this ISO has neither. It is a snapshot of one machine's state, not a contract about stock behavior, and trusting it cost two test cycles. ### Non-interactivity belongs to the dashboard @@ -109,12 +114,14 @@ The dashboard is the only process that owns the visible UI. Suppressing its prom ### Changes - `omarchy-iso-install`: add `--ssh-keys-file` to the arg loop, exporting `OMARCHY_INSTALL_SSH_KEYS_FILE`. Same shape as the five existing flags, ahead of the `unknown arg` catch-all (`:19`). - `context.py`: add an `ssh_keys_path: Path | None` field populated from that variable in `from_env`, `None` when unset or when the file does not exist. -- `phases_impl.py`: add `configure_remote_access(ctx)`: +- `phases_impl.py`: add `configure_ssh_access(ctx)`: - Return immediately when `ctx.ssh_keys_path` is `None`. - Parse the file as a JSON array of public key strings; write them one per line to `/home//.ssh/authorized_keys`. - `.ssh` at `0700`, `authorized_keys` at `0600`, both owned by the user via `arch-chroot chown -R :`. Do not hardcode uid 1000 as PR #72 did — ask the target for it. + - `arch-chroot systemctl enable sshd.service`. Works cleanly in the chroot. + - `arch-chroot ufw allow ssh` when the target has ufw. This one exits **non-zero** in a chroot (`ERROR: problem running`) because ufw cannot reach netfilter, but it writes the rule to `/etc/ufw/user.rules` before failing, and that file is what `ufw.service` loads on first boot. So ignore the exit status and assert the rule landed in the file instead. - A malformed or empty `ssh.json` must fail the phase loudly. A machine that installs "successfully" and is then unreachable is worse than one that stops with an error on screen. -- `main.py`: register `("Configuring remote access", configure_remote_access)` in `build_phases` after `configure_login` (`:52`) and before `validate_boot`. +- `main.py`: register `("Configuring SSH access", configure_ssh_access)` in `build_phases` after `configure_login` (`:52`) and before `validate_boot`. ### Model to follow `configure_login` (`phases_impl.py:1251`) — direct writes against `ctx.target`, `arch-chroot` reserved for ownership and `systemctl`. Match its structure. @@ -123,6 +130,7 @@ The dashboard is the only process that owns the visible UI. Suppressing its prom - Install with `ssh.json`: `ssh @` works with the corresponding private key on first boot, no password. - Install without `ssh.json`: phase runs, does nothing, install unaffected; `~/.ssh` is not created. - `authorized_keys` is `0600` and owned by the install user, not root. +- Port 22 is open in ufw on the installed system. Test this by connecting, not by reading the phase log — the rule is written from a chroot that cannot verify it. - Password SSH auth is left at the distro default — autoinstall does not enable it. - Interactive installs are unaffected: the flag is passed but the file never exists. @@ -169,4 +177,4 @@ PR #72 targeted the pre-`quattro` tree and cannot be rebased: `use_omarchy_helpe - The `systemd-networkd` DHCP unit — unnecessary; NetworkManager is already enabled and would contend with it. - The `PasswordAuthentication yes` rewrite — unnecessary and a security downgrade. -`load_cidata` is the one piece that carries over close to intact. +`load_cidata` is the one piece that carries over close to intact. PR #72's `ufw allow ssh` also carries over: it was dropped from the first draft of this plan on the reasoning that an enabled sshd is a reachable sshd, and a test install proved otherwise. That PR was right to include it. From e8a7e3cfcecab0dcf3c349edfde212351a864053 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Tue, 4 Aug 2026 05:56:33 -0700 Subject: [PATCH 07/11] Extract cidata loading into a standalone omarchy-cidata-load command The loader now settles udev before probing, so a fast boot cannot miss the drive and silently drop a headless install into the wizard. All the files come over in a single cp, and a failed copy removes anything it already copied and falls back to the wizard: the wizard cleans up only what it writes itself, so a half-copied ssh.json would otherwise leak into the interactive install that follows. The command takes an optional path prefix so tests can run it against a sandbox instead of the live system. Co-Authored-By: Claude Fable 5 --- configs/airootfs/root/.automated_script.sh | 37 ++---------- .../usr/local/bin/omarchy-cidata-load | 57 +++++++++++++++++++ configs/profiledef.sh | 1 + 3 files changed, 62 insertions(+), 33 deletions(-) create mode 100755 configs/airootfs/usr/local/bin/omarchy-cidata-load diff --git a/configs/airootfs/root/.automated_script.sh b/configs/airootfs/root/.automated_script.sh index b58f96b..dffe289 100644 --- a/configs/airootfs/root/.automated_script.sh +++ b/configs/airootfs/root/.automated_script.sh @@ -90,40 +90,11 @@ warm_offline_mirror & warm_pid=$! trap 'kill "$warm_pid" 2>/dev/null' EXIT -# Autoinstall: a drive labeled cidata (the cloud-init NoCloud label, so Proxmox, -# libvirt and Packer all know how to attach one) carrying the configurator's own -# output stands in for the wizard. Copy those files into /root and everything -# downstream runs the ordinary path against ordinary inputs. -# -# The required pair is the same one the orchestrator treats as mandatory; the -# rest are optional there too, so don't be stricter here. Anything less than the -# pair means this isn't an autoinstall drive, and we fall back to the wizard. -load_cidata() { - local device="" label mnt=/run/cidata - - for label in cidata CIDATA; do - [[ -e /dev/disk/by-label/$label ]] && { device=/dev/disk/by-label/$label; break; } - done - [[ -n $device ]] || return 1 - - mkdir -p "$mnt" - mount -o ro "$device" "$mnt" || return 1 - - if [[ -f $mnt/user_configuration.json && -f $mnt/user_credentials.json ]]; then - cp "$mnt"/user_configuration.json "$mnt"/user_credentials.json /root/ - for file in user_full_name.txt user_email_address.txt user_encrypt_installation.txt ssh.json; do - [[ -f $mnt/$file ]] && cp "$mnt/$file" /root/ - done - umount "$mnt" - return 0 - fi - - umount "$mnt" - return 1 -} - cd /root -if load_cidata; then +# Autoinstall: a cidata drive carrying the configurator's own output files +# stands in for the wizard. omarchy-cidata-load copies them into /root and +# everything downstream runs the ordinary path against ordinary inputs. +if /usr/local/bin/omarchy-cidata-load; then echo "Autoinstall configuration found on cidata drive; skipping the configurator." export OMARCHY_UI_INTERACTIVE=no else diff --git a/configs/airootfs/usr/local/bin/omarchy-cidata-load b/configs/airootfs/usr/local/bin/omarchy-cidata-load new file mode 100755 index 0000000..24189e2 --- /dev/null +++ b/configs/airootfs/usr/local/bin/omarchy-cidata-load @@ -0,0 +1,57 @@ +#!/bin/bash +# +# Autoinstall: a drive labeled cidata (the cloud-init NoCloud label, so Proxmox, +# libvirt and Packer all know how to attach one) carrying the configurator's own +# output files stands in for the wizard. Copy those files into /root and +# everything downstream runs the ordinary path against ordinary inputs. +# +# Exits 0 when an autoinstall drive was loaded and the configurator can be +# skipped. The required pair is the same one the orchestrator treats as +# mandatory; the rest are optional there too, so don't be stricter here. +# Anything less than the pair means this isn't an autoinstall drive, and the +# caller falls back to the wizard. +# +# The optional argument prefixes every path the script touches; tests use it to +# run against a sandbox instead of the live system. + +set -uo pipefail + +root=${1:-} +by_label=$root/dev/disk/by-label +dest=$root/root +mnt=$root/run/cidata + +# The by-label symlinks come from udev, and on a fast boot this can run before +# device enumeration has finished. Wait for the queue to drain instead of +# racing it and silently dropping a headless install into the wizard. +udevadm settle 2>/dev/null || true + +device="" +for label in cidata CIDATA; do + [[ -e $by_label/$label ]] && { device=$by_label/$label; break; } +done +[[ -n $device ]] || exit 1 + +mkdir -p "$mnt" +mount -o ro "$device" "$mnt" || exit 1 + +loaded=1 +if [[ -f $mnt/user_configuration.json && -f $mnt/user_credentials.json ]]; then + files=("$mnt"/user_configuration.json "$mnt"/user_credentials.json) + for file in user_full_name.txt user_email_address.txt user_encrypt_installation.txt ssh.json; do + [[ -f $mnt/$file ]] && files+=("$mnt/$file") + done + if cp "${files[@]}" "$dest"/; then + loaded=0 + else + # A partial copy must not leave files behind: the wizard cleans up only + # what it writes itself, so a half-copied ssh.json would leak into the + # interactive install that follows. + for file in "${files[@]}"; do + rm -f "$dest/${file##*/}" + done + fi +fi + +umount "$mnt" +exit $loaded diff --git a/configs/profiledef.sh b/configs/profiledef.sh index 8dc0f2d..268a3ce 100644 --- a/configs/profiledef.sh +++ b/configs/profiledef.sh @@ -36,6 +36,7 @@ file_permissions=( ["/root/.gnupg"]="0:0:700" ["/root/configurator"]="0:0:755" ["/usr/local/bin/choose-mirror"]="0:0:755" + ["/usr/local/bin/omarchy-cidata-load"]="0:0:755" ["/usr/local/bin/omarchy-iso-cleanup-disk"]="0:0:755" ["/usr/local/bin/omarchy-install-dashboard"]="0:0:755" ["/usr/local/bin/omarchy-iso-install"]="0:0:755" From dd5c53cd3c610610098af3f20f2cac26342f73f9 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Tue, 4 Aug 2026 05:56:33 -0700 Subject: [PATCH 08/11] Trust the target to have ufw when opening SSH access ufw is part of Omarchy's default package set, so the presence check was guarding a machine that cannot exist -- and if it somehow did, skipping the firewall would produce an unreachable install, which is exactly what this phase fails loudly to prevent. The user.rules assertion already raises in that case. With the check gone, the helper is small enough to live inline in the phase. Co-Authored-By: Claude Fable 5 --- .../omarchy-iso/orchestrator/phases_impl.py | 28 ++++++------------- 1 file changed, 9 insertions(+), 19 deletions(-) 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 ba0a639..4be987c 100644 --- a/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py +++ b/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py @@ -1343,25 +1343,15 @@ def configure_ssh_access(ctx: InstallContext) -> None: check=True, ) - _allow_ssh_through_firewall(ctx) - - -def _allow_ssh_through_firewall(ctx: InstallContext) -> None: - """Open port 22 in the target's ufw. - - Omarchy's install/config/firewall.sh opens LocalSend and docker DNS and - nothing else, and ufw runs default-deny incoming, so an enabled sshd is - still unreachable -- connections time out rather than being refused. - - ufw cannot reach netfilter from inside the chroot and exits non-zero saying - so, but it writes the rule to user.rules first, and that file is what - ufw.service loads on first boot. So the exit status is the wrong thing to - check here; the rule landing in the file is the thing that matters. - """ - if not (ctx.target / "usr" / "bin" / "ufw").exists(): - info("› no ufw in target, leaving firewall alone") - return - + # Open port 22 in the target's ufw, which runs default-deny incoming, so an + # enabled sshd is still unreachable -- connections time out rather than + # being refused. + # + # ufw cannot reach netfilter from inside the chroot and exits non-zero + # saying so, but it writes the rule to user.rules first, and that file is + # what ufw.service loads on first boot. So the exit status is the wrong + # thing to check here; the rule landing in the file is the thing that + # matters. info("› allowing SSH through ufw") subprocess.run(["arch-chroot", str(ctx.target), "ufw", "allow", "ssh"], check=False) From 69ec1b4b8e095e8872925725fc7bcc3146f1e788 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Tue, 4 Aug 2026 05:56:33 -0700 Subject: [PATCH 09/11] Add unit tests for cidata loading and the SSH access phase ./test/all runs every suite under test/: the shell tests exercise omarchy-cidata-load against a sandbox with mount/umount/udevadm stubbed, and test_configure_ssh_access.py runs the orchestrator phase against a temp target with subprocess.run recorded and ufw's write-the-rule-then-fail chroot behavior simulated. The Python tests go through unittest discovery, which also picks up the repo's existing test_*.py suites that previously had no runner. Co-Authored-By: Claude Fable 5 --- test/all | 13 +++ test/cidata-load-test.sh | 170 ++++++++++++++++++++++++++++++ test/test_configure_ssh_access.py | 157 +++++++++++++++++++++++++++ 3 files changed, 340 insertions(+) create mode 100755 test/all create mode 100644 test/cidata-load-test.sh create mode 100644 test/test_configure_ssh_access.py diff --git a/test/all b/test/all new file mode 100755 index 0000000..fd91768 --- /dev/null +++ b/test/all @@ -0,0 +1,13 @@ +#!/bin/bash + +set -euo pipefail + +ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) + +for test in "$ROOT"/test/*-test.sh; do + printf '==> %s\n' "${test#$ROOT/}" + bash "$test" +done + +printf '==> test/test_*.py\n' +python3 -m unittest discover -t "$ROOT" -s "$ROOT/test" diff --git a/test/cidata-load-test.sh b/test/cidata-load-test.sh new file mode 100644 index 0000000..ede60fa --- /dev/null +++ b/test/cidata-load-test.sh @@ -0,0 +1,170 @@ +#!/bin/bash +# +# Unit tests for omarchy-cidata-load. The script takes a path prefix, so every +# case runs against a throwaway sandbox with mount/umount/udevadm stubbed out: +# "mounting" copies the fake drive's contents into the mountpoint, and every +# stub logs its invocation so the cases can assert what was (not) called. + +set -euo pipefail + +ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +CIDATA_LOAD="$ROOT/configs/airootfs/usr/local/bin/omarchy-cidata-load" + +pass() { + printf 'ok - %s\n' "$1" +} + +fail() { + local description="$1" + local detail="${2:-}" + + [[ -n $detail ]] && printf '%s\n' "$detail" >&2 + printf 'not ok - %s\n' "$description" >&2 + exit 1 +} + +work=$(mktemp -d) +trap 'chmod -R u+w "$work"; rm -rf "$work"' EXIT + +stub_dir="$work/stubs" +mkdir -p "$stub_dir" + +cat >"$stub_dir/udevadm" <<'STUB' +#!/bin/bash +printf 'udevadm %s\n' "$*" >>"$TEST_LOG" +STUB + +cat >"$stub_dir/mount" <<'STUB' +#!/bin/bash +printf 'mount %s\n' "$*" >>"$TEST_LOG" +[[ ${MOUNT_FAIL:-} == 1 ]] && exit 32 +device=$3 mountpoint=$4 +cp -a "$(readlink -f "$device")"/. "$mountpoint"/ +STUB + +cat >"$stub_dir/umount" <<'STUB' +#!/bin/bash +printf 'umount %s\n' "$*" >>"$TEST_LOG" +STUB + +chmod +x "$stub_dir"/* + +new_sandbox() { + sandbox=$(mktemp -d "$work/sandbox.XXXXXX") + mkdir -p "$sandbox/dev/disk/by-label" "$sandbox/root" "$sandbox/media" + export TEST_LOG="$sandbox/calls.log" + : >"$TEST_LOG" +} + +attach_drive() { + ln -s "$sandbox/media" "$sandbox/dev/disk/by-label/$1" +} + +run_load() { + PATH="$stub_dir:$PATH" "$CIDATA_LOAD" "$sandbox" +} + +write_required_pair() { + echo '{"disk_config": {}}' >"$sandbox/media/user_configuration.json" + echo '{"users": []}' >"$sandbox/media/user_credentials.json" +} + +# No cidata drive attached: fall back to the wizard without mounting anything. +new_sandbox +! run_load || fail "no drive exits non-zero" +! grep -q '^mount ' "$TEST_LOG" || fail "no drive mounts nothing" +pass "no drive falls back to the wizard" + +# The probe must wait for udev to finish enumerating before concluding there +# is no drive. +grep -q '^udevadm settle$' "$TEST_LOG" || fail "probe settles udev first" +pass "probe settles udev first" + +# A drive with the full file set: everything lands in /root and the drive is +# unmounted afterwards. +new_sandbox +attach_drive cidata +write_required_pair +echo "Jeff" >"$sandbox/media/user_full_name.txt" +echo "jeff@example.com" >"$sandbox/media/user_email_address.txt" +echo "false" >"$sandbox/media/user_encrypt_installation.txt" +echo '["ssh-ed25519 AAAA jeff@host"]' >"$sandbox/media/ssh.json" +run_load || fail "full file set loads" +for file in user_configuration.json user_credentials.json user_full_name.txt user_email_address.txt user_encrypt_installation.txt ssh.json; do + [[ -f $sandbox/root/$file ]] || fail "full file set copies $file" +done +grep -q '^umount ' "$TEST_LOG" || fail "full file set unmounts the drive" +pass "full file set loads, copies everything, and unmounts" + +# The uppercase label variant some tools produce works too. +new_sandbox +attach_drive CIDATA +write_required_pair +run_load || fail "uppercase CIDATA label loads" +pass "uppercase CIDATA label loads" + +# The required pair alone is a valid autoinstall drive; the optional files +# stay optional. +new_sandbox +attach_drive cidata +write_required_pair +run_load || fail "required pair alone loads" +[[ ! -e $sandbox/root/ssh.json ]] || fail "required pair alone copies no optional files" +pass "required pair alone loads without optional files" + +# Optional files are copied individually when present. +new_sandbox +attach_drive cidata +write_required_pair +echo '["ssh-ed25519 AAAA jeff@host"]' >"$sandbox/media/ssh.json" +run_load || fail "required pair plus ssh.json loads" +[[ -f $sandbox/root/ssh.json ]] || fail "ssh.json is copied when present" +[[ ! -e $sandbox/root/user_full_name.txt ]] || fail "absent optional files are not copied" +pass "present optional files are copied, absent ones skipped" + +# Half the required pair is not an autoinstall drive: unmount and fall back. +new_sandbox +attach_drive cidata +echo '{"disk_config": {}}' >"$sandbox/media/user_configuration.json" +! run_load || fail "half the required pair exits non-zero" +[[ ! -e $sandbox/root/user_configuration.json ]] || fail "half the required pair copies nothing" +grep -q '^umount ' "$TEST_LOG" || fail "half the required pair still unmounts" +pass "half the required pair falls back and unmounts" + +# An empty drive labeled cidata is not an autoinstall drive either. +new_sandbox +attach_drive cidata +! run_load || fail "empty drive exits non-zero" +grep -q '^umount ' "$TEST_LOG" || fail "empty drive still unmounts" +pass "empty drive falls back and unmounts" + +# A drive that will not mount falls back rather than failing the boot. +new_sandbox +attach_drive cidata +write_required_pair +! MOUNT_FAIL=1 run_load || fail "mount failure exits non-zero" +! grep -q '^umount ' "$TEST_LOG" || fail "mount failure has nothing to unmount" +pass "mount failure falls back to the wizard" + +# A copy failure must not report a loaded drive: the install would start with +# missing inputs. Fall back and let the wizard produce them instead. +new_sandbox +attach_drive cidata +write_required_pair +chmod 555 "$sandbox/root" +! run_load 2>/dev/null || fail "copy failure exits non-zero" +grep -q '^umount ' "$TEST_LOG" || fail "copy failure still unmounts" +pass "copy failure falls back and unmounts" + +# A partial copy must clean up after itself: the wizard removes only what it +# writes, so anything the loader left behind would leak into the interactive +# install that follows. +new_sandbox +attach_drive cidata +write_required_pair +echo '["ssh-ed25519 AAAA jeff@host"]' >"$sandbox/media/ssh.json" +mkdir "$sandbox/root/user_credentials.json" +! run_load 2>/dev/null || fail "partial copy exits non-zero" +[[ ! -e $sandbox/root/user_configuration.json ]] || fail "partial copy removes what it copied" +[[ ! -e $sandbox/root/ssh.json ]] || fail "partial copy leaves no ssh.json behind" +pass "partial copy cleans up what it copied" diff --git a/test/test_configure_ssh_access.py b/test/test_configure_ssh_access.py new file mode 100644 index 0000000..71159af --- /dev/null +++ b/test/test_configure_ssh_access.py @@ -0,0 +1,157 @@ +"""Unit tests for the orchestrator's configure_ssh_access phase. + +The phase touches the target only through the filesystem and arch-chroot, so +the tests run it against a temp directory with subprocess.run recorded and the +ufw side effect (writing user.rules) simulated by the fake. +""" + +import json +import stat +import sys +import tempfile +import types +import unittest +from pathlib import Path +from subprocess import CompletedProcess +from unittest import mock + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "configs/airootfs/usr/share/omarchy-iso")) + +# phases_impl imports the archinstall adapter at module scope, which pulls in +# the archinstall library that only exists on the live ISO. configure_ssh_access +# never touches it, so stub the adapter out before the import. +sys.modules["orchestrator.archinstall_adapter"] = types.ModuleType("orchestrator.archinstall_adapter") + +from orchestrator import phases_impl # noqa: E402 + +UFW_RULE = "-A ufw-user-input -p tcp --dport 22 -j ACCEPT\n" + + +class ConfigureSshAccessTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.target = Path(self.tmp.name) + self.calls = [] + + info_patch = mock.patch.object(phases_impl, "info") + info_patch.start() + self.addCleanup(info_patch.stop) + + run_patch = mock.patch.object(phases_impl.subprocess, "run", side_effect=self.fake_run) + run_patch.start() + self.addCleanup(run_patch.stop) + + self.ufw_writes_rule = True + + def fake_run(self, cmd, **kwargs): + self.calls.append(cmd) + if cmd[2] == "ufw": + if self.ufw_writes_rule: + rules = self.target / "etc" / "ufw" / "user.rules" + rules.parent.mkdir(parents=True, exist_ok=True) + rules.write_text(UFW_RULE) + # ufw cannot reach netfilter inside a chroot and exits non-zero + # even when it recorded the rule; the phase must not trust this. + return CompletedProcess(cmd, 1) + return CompletedProcess(cmd, 0) + + def ctx(self, ssh_keys=None, ssh_keys_text=None): + ssh_keys_path = None + if ssh_keys is not None or ssh_keys_text is not None: + ssh_keys_path = self.target / "ssh.json" + ssh_keys_path.write_text(ssh_keys_text if ssh_keys_text is not None else json.dumps(ssh_keys)) + return types.SimpleNamespace(target=self.target, username="jeff", ssh_keys_path=ssh_keys_path) + + def configure(self, **kwargs): + phases_impl.configure_ssh_access(self.ctx(**kwargs)) + + def authorized_keys(self): + return self.target / "home" / "jeff" / ".ssh" / "authorized_keys" + + def chrooted(self, program): + return [cmd for cmd in self.calls if cmd[:2] == ["arch-chroot", str(self.target)] and cmd[2] == program] + + def test_no_ssh_json_is_a_no_op(self): + self.configure() + self.assertEqual(self.calls, []) + self.assertFalse((self.target / "home" / "jeff" / ".ssh").exists()) + + def test_writes_keys_one_per_line(self): + self.configure(ssh_keys=["ssh-ed25519 AAAA jeff@host", "ssh-rsa BBBB jeff@work"]) + self.assertEqual(self.authorized_keys().read_text(), "ssh-ed25519 AAAA jeff@host\nssh-rsa BBBB jeff@work\n") + + def test_strips_whitespace_and_drops_blank_entries(self): + self.configure(ssh_keys=[" ssh-ed25519 AAAA jeff@host\n", "", " "]) + self.assertEqual(self.authorized_keys().read_text(), "ssh-ed25519 AAAA jeff@host\n") + + def test_ssh_dir_is_private(self): + self.configure(ssh_keys=["ssh-ed25519 AAAA jeff@host"]) + mode = stat.S_IMODE((self.target / "home" / "jeff" / ".ssh").stat().st_mode) + self.assertEqual(mode, 0o700) + + def test_authorized_keys_is_private(self): + self.configure(ssh_keys=["ssh-ed25519 AAAA jeff@host"]) + self.assertEqual(stat.S_IMODE(self.authorized_keys().stat().st_mode), 0o600) + + def test_chowns_ssh_dir_to_the_user(self): + self.configure(ssh_keys=["ssh-ed25519 AAAA jeff@host"]) + self.assertEqual(self.chrooted("chown"), [ + ["arch-chroot", str(self.target), "chown", "-R", "jeff:jeff", "/home/jeff/.ssh"], + ]) + + def test_enables_sshd(self): + self.configure(ssh_keys=["ssh-ed25519 AAAA jeff@host"]) + self.assertEqual(self.chrooted("systemctl"), [ + ["arch-chroot", str(self.target), "systemctl", "enable", "sshd.service"], + ]) + + def test_allows_ssh_through_ufw_despite_chroot_exit_status(self): + self.configure(ssh_keys=["ssh-ed25519 AAAA jeff@host"]) + self.assertEqual(self.chrooted("ufw"), [ + ["arch-chroot", str(self.target), "ufw", "allow", "ssh"], + ]) + + def test_fails_when_ufw_does_not_record_the_rule(self): + self.ufw_writes_rule = False + with self.assertRaisesRegex(RuntimeError, "allow rule for port 22"): + self.configure(ssh_keys=["ssh-ed25519 AAAA jeff@host"]) + + def test_malformed_json_fails_the_phase(self): + with self.assertRaisesRegex(RuntimeError, "not readable JSON"): + self.configure(ssh_keys_text="not json") + + def test_non_array_json_fails_the_phase(self): + with self.assertRaisesRegex(RuntimeError, "JSON array of public key strings"): + self.configure(ssh_keys={"key": "ssh-ed25519 AAAA"}) + + def test_non_string_entries_fail_the_phase(self): + with self.assertRaisesRegex(RuntimeError, "JSON array of public key strings"): + self.configure(ssh_keys=["ssh-ed25519 AAAA jeff@host", 42]) + + def test_empty_key_list_fails_the_phase(self): + with self.assertRaisesRegex(RuntimeError, "contains no SSH keys"): + self.configure(ssh_keys=[]) + + def test_blank_only_key_list_fails_the_phase(self): + with self.assertRaisesRegex(RuntimeError, "contains no SSH keys"): + self.configure(ssh_keys=["", " "]) + + +class OptionalPathTest(unittest.TestCase): + def test_unset_and_missing_paths_are_none(self): + from orchestrator.context import _optional_path + + self.assertIsNone(_optional_path(None)) + self.assertIsNone(_optional_path("")) + self.assertIsNone(_optional_path("/does/not/exist/ssh.json")) + + def test_existing_path_is_returned(self): + from orchestrator.context import _optional_path + + with tempfile.NamedTemporaryFile() as f: + self.assertEqual(_optional_path(f.name), Path(f.name)) + + +if __name__ == "__main__": + unittest.main() From be68a76a4421d308a725c558b9d867b96153b7da Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Tue, 4 Aug 2026 05:56:33 -0700 Subject: [PATCH 10/11] True up the autoinstall docs user_encrypt_installation.txt does not encrypt anything: encryption is driven by the disk_encryption block inside user_configuration.json, which carries the passphrase in plaintext. The flag must match that block, since it drives the encrypted install's autologin and the final boot validation. The plan also claimed sshd ships enabled -- it ships disabled, which is the whole reason the SSH phase exists -- and now reflects the extracted loader and the unit tests. Co-Authored-By: Claude Fable 5 --- README.md | 6 +++++- plans/autoinstall.md | 33 ++++++++++++++++++++------------- 2 files changed, 25 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index b354747..2e2b53c 100644 --- a/README.md +++ b/README.md @@ -36,11 +36,13 @@ These are the configurator's own output files, so the way to get a starting set | `user_credentials.json` | Yes | Username and password hash | | `user_full_name.txt` | No | Git full name | | `user_email_address.txt` | No | Git email | -| `user_encrypt_installation.txt` | No | `true` to encrypt the install; defaults to false | +| `user_encrypt_installation.txt` | No | `true` when `user_configuration.json` carries a `disk_encryption` block; defaults to false | | `ssh.json` | No | JSON array of SSH public keys | Both required files must be present or the installer falls back to the configurator. Generate the password hash for `user_credentials.json` with `openssl passwd -6 "yourpassword"`. +Encryption itself is configured by the `disk_encryption` block inside `user_configuration.json` — which carries the passphrase in plaintext, so treat a drive built from an encrypted install accordingly. The flag file must match it: it drives the encrypted install's SDDM autologin and the final boot validation, not the encryption. + `ssh.json` is a JSON array of public keys: ```json @@ -81,6 +83,8 @@ Encrypted autoinstalls are not fully unattended — the LUKS passphrase prompt s Run `./bin/omarchy-iso-boot [release/omarchy.iso]`. +Run `./test/all` for the unit tests, which cover cidata autoinstall loading and the orchestrator's SSH access phase without needing a built ISO. + To exercise installation alongside existing Windows-style partitions, run `./bin/omarchy-iso-test-windows-disk [release/omarchy.iso]`. It creates a synthetic disk in `/tmp` with an existing ESP and data partition plus ample diff --git a/plans/autoinstall.md b/plans/autoinstall.md index 023621d..ed318d2 100644 --- a/plans/autoinstall.md +++ b/plans/autoinstall.md @@ -57,22 +57,26 @@ Do not take `manifests/fresh-4-semantic.json` as the authority on any of this. I ## 1) Detect the cidata drive and skip the configurator -### File +### Files +- `configs/airootfs/usr/local/bin/omarchy-cidata-load` - `configs/airootfs/root/.automated_script.sh` +- `configs/profiledef.sh` ### Changes -- Add a `load_cidata` function above the existing `cd /root` (`:93`): - - Look for `/dev/disk/by-label/cidata` then `/dev/disk/by-label/CIDATA`; return non-zero if neither exists. - - Mount read-only on a temp mountpoint; return non-zero if the mount fails. - - Require both `user_configuration.json` and `user_credentials.json` on the drive — this is the same pair `InstallContext.from_env` treats as mandatory. Missing either means the drive is not an autoinstall drive; unmount and return non-zero. - - Copy the required pair plus, when present, `user_full_name.txt`, `user_email_address.txt`, `user_encrypt_installation.txt`, and `ssh.json` into `/root`. - - Unmount before returning. Leave nothing mounted for the install to trip over. -- Replace the bare `./configurator` call (`:94`) with: run `load_cidata`; on success export `OMARCHY_UI_INTERACTIVE=no`, on failure run `./configurator`. -- Pass `--ssh-keys-file /root/ssh.json` to `omarchy-iso-install` in the dashboard invocation (`:101`–`:110`), alongside the existing five flags. Always pass it; the orchestrator no-ops when the file is absent, which keeps the interactive path on the identical command line. +- Add `omarchy-cidata-load` alongside the other install commands in `/usr/local/bin` (with the matching `file_permissions` entry in `profiledef.sh`). It exits 0 when an autoinstall drive was loaded: + - `udevadm settle` first: the by-label symlinks come from udev, and probing before enumeration finishes would silently drop a headless install into the wizard. + - Look for `/dev/disk/by-label/cidata` then `/dev/disk/by-label/CIDATA`; exit non-zero if neither exists. + - Mount read-only on a temp mountpoint; exit non-zero if the mount fails. + - Require both `user_configuration.json` and `user_credentials.json` on the drive — this is the same pair `InstallContext.from_env` treats as mandatory. Missing either means the drive is not an autoinstall drive; unmount and exit non-zero. + - Copy the required pair plus, when present, `user_full_name.txt`, `user_email_address.txt`, `user_encrypt_installation.txt`, and `ssh.json` into `/root` with a single `cp`, and exit non-zero if the copy fails — a "loaded" drive with missing inputs would fail the install later with a worse error. + - Unmount before exiting. Leave nothing mounted for the install to trip over. + - The script takes an optional path prefix so the unit tests can point it at a sandbox; production callers pass nothing. +- Replace the bare `./configurator` call in `.automated_script.sh` with: run `omarchy-cidata-load`; on success export `OMARCHY_UI_INTERACTIVE=no`, on failure run `./configurator`. +- Pass `--ssh-keys-file /root/ssh.json` to `omarchy-iso-install` in the dashboard invocation, alongside the existing five flags. Always pass it; the orchestrator no-ops when the file is absent, which keeps the interactive path on the identical command line. ### Notes - The three text files are optional by construction: `_read_text` in `context.py` returns `""` for a missing path and `encrypt` falls back to false. Do not add stricter checks here than the orchestrator itself applies. -- `load_cidata` must not run before `warm_offline_mirror` is backgrounded — the mirror warm should keep its head start. +- `omarchy-cidata-load` must not run before `warm_offline_mirror` is backgrounded — the mirror warm should keep its head start. ### Acceptance criteria - ISO with no `cidata` drive attached: configurator runs, byte-identical UX to today. @@ -119,7 +123,7 @@ The dashboard is the only process that owns the visible UI. Suppressing its prom - Parse the file as a JSON array of public key strings; write them one per line to `/home//.ssh/authorized_keys`. - `.ssh` at `0700`, `authorized_keys` at `0600`, both owned by the user via `arch-chroot chown -R :`. Do not hardcode uid 1000 as PR #72 did — ask the target for it. - `arch-chroot systemctl enable sshd.service`. Works cleanly in the chroot. - - `arch-chroot ufw allow ssh` when the target has ufw. This one exits **non-zero** in a chroot (`ERROR: problem running`) because ufw cannot reach netfilter, but it writes the rule to `/etc/ufw/user.rules` before failing, and that file is what `ufw.service` loads on first boot. So ignore the exit status and assert the rule landed in the file instead. + - `arch-chroot ufw allow ssh`. This one exits **non-zero** in a chroot (`ERROR: problem running`) because ufw cannot reach netfilter, but it writes the rule to `/etc/ufw/user.rules` before failing, and that file is what `ufw.service` loads on first boot. So ignore the exit status and assert the rule landed in the file instead. No ufw-presence check: ufw is part of Omarchy's default package set, and the rule assertion already fails loudly if it is somehow missing. - A malformed or empty `ssh.json` must fail the phase loudly. A machine that installs "successfully" and is then unreachable is worse than one that stops with an error on screen. - `main.py`: register `("Configuring SSH access", configure_ssh_access)` in `build_phases` after `configure_login` (`:52`) and before `validate_boot`. @@ -145,12 +149,15 @@ The dashboard is the only process that owns the visible UI. Suppressing its prom - New "Autoinstall" section covering: what the `cidata` drive is, the file table, how to produce the drive, and a Proxmox `qm create` example with disk-first boot order so the empty disk falls through to the ISO on the first boot and boots the installed system afterward. - Show `openssl passwd -6` for the credential hash and the `ssh.json` array format. - State plainly that the config files are the configurator's own output — the documented way to get a starting `user_configuration.json` is to run one interactive install and copy what it wrote. -- Do not repeat PR #72's networking claims. Say that sshd and NetworkManager come enabled on a stock install and that autoinstall only adds the key. +- Do not repeat PR #72's networking claims. NetworkManager comes enabled with DHCP on a stock install; sshd ships disabled, which is why the SSH phase exists. --- ## 5) Validation +### Unit tests +`./test/all` runs everything under `test/`: `cidata-load-test.sh` exercises `omarchy-cidata-load` against a sandbox with `mount`/`umount`/`udevadm` stubbed, and `test_configure_ssh_access.py` runs the orchestrator phase against a temp target with `subprocess.run` recorded and ufw's write-the-rule-then-fail chroot behavior simulated. The Python tests run through `unittest` discovery alongside the repo's existing `test_*.py` suites. + ### Manual matrix 1. Build the ISO from this branch. 2. Interactive control run: boot with no `cidata` drive, install normally, confirm nothing changed. @@ -165,7 +172,7 @@ The dashboard is the only process that owns the visible UI. Suppressing its prom - Install failure under autoinstall (e.g. a disk target that does not exist) → failure screen renders, process exits non-zero, no hang. ### Regression surface -The interactive path shares every line of this change except the `load_cidata` branch, so run the standard acceptance harness (`./bin/omarchy-iso-test`) on the built ISO before proposing the PR. +The interactive path shares every line of this change except the `omarchy-cidata-load` branch, so run the standard acceptance harness (`./bin/omarchy-iso-test`) on the built ISO before proposing the PR. --- From f11a70c6ba30fad3008c7a25c910cc66034b12bb Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Tue, 4 Aug 2026 06:08:33 -0700 Subject: [PATCH 11/11] Take authorized_keys on the cidata drive instead of ssh.json The JSON wrapper was the one piece of autoinstall input that wasn't an existing format: the other files are the configurator's own output, and the keys end up in sshd's authorized_keys anyway. So take that file directly -- one key per line, comments and key options work as they do everywhere else -- and drop the parsing in between. The only thing JSON bought was parse-error detection, and the phase still fails loudly when the file carries no usable keys. Co-Authored-By: Claude Fable 5 --- README.md | 12 ++-- configs/airootfs/root/.automated_script.sh | 2 +- .../usr/local/bin/omarchy-cidata-load | 6 +- .../usr/local/bin/omarchy-iso-install | 14 ++-- .../share/omarchy-iso/orchestrator/context.py | 8 +-- .../omarchy-iso/orchestrator/phases_impl.py | 20 +++--- plans/autoinstall.md | 22 +++---- test/cidata-load-test.sh | 16 ++--- test/test_configure_ssh_access.py | 66 +++++++++---------- 9 files changed, 80 insertions(+), 86 deletions(-) diff --git a/README.md b/README.md index 2e2b53c..2cafd76 100644 --- a/README.md +++ b/README.md @@ -37,25 +37,25 @@ These are the configurator's own output files, so the way to get a starting set | `user_full_name.txt` | No | Git full name | | `user_email_address.txt` | No | Git email | | `user_encrypt_installation.txt` | No | `true` when `user_configuration.json` carries a `disk_encryption` block; defaults to false | -| `ssh.json` | No | JSON array of SSH public keys | +| `authorized_keys` | No | SSH public keys in sshd's own format, one per line | Both required files must be present or the installer falls back to the configurator. Generate the password hash for `user_credentials.json` with `openssl passwd -6 "yourpassword"`. Encryption itself is configured by the `disk_encryption` block inside `user_configuration.json` — which carries the passphrase in plaintext, so treat a drive built from an encrypted install accordingly. The flag file must match it: it drives the encrypted install's SDDM autologin and the final boot validation, not the encryption. -`ssh.json` is a JSON array of public keys: +`authorized_keys` is the same file sshd reads — copy your own or write one key per line: -```json -["ssh-ed25519 AAAA... you@host"] +``` +ssh-ed25519 AAAA... you@host ``` -When `ssh.json` is present, autoinstall writes the keys to the user's `~/.ssh/authorized_keys`, enables `sshd`, and adds a `ufw allow ssh` rule — a stock Omarchy install ships openssh with the service disabled and its firewall opens neither port 22 nor anything else beyond LocalSend. Networking needs nothing extra; NetworkManager is already enabled with DHCP. Password SSH authentication is left at the distro default. An unparseable or empty `ssh.json` fails the install rather than producing a machine nobody can reach. +When `authorized_keys` is present, autoinstall installs it as the user's `~/.ssh/authorized_keys`, enables `sshd`, and adds a `ufw allow ssh` rule — a stock Omarchy install ships openssh with the service disabled and its firewall opens neither port 22 nor anything else beyond LocalSend. Networking needs nothing extra; NetworkManager is already enabled with DHCP. Password SSH authentication is left at the distro default. An `authorized_keys` with no usable keys fails the install rather than producing a machine nobody can reach. ### Building the drive ```bash mkdir cidata -cp user_configuration.json user_credentials.json ssh.json cidata/ +cp user_configuration.json user_credentials.json authorized_keys cidata/ genisoimage -output cidata.iso -volid cidata -joliet -rock cidata/ ``` diff --git a/configs/airootfs/root/.automated_script.sh b/configs/airootfs/root/.automated_script.sh index dffe289..29f6262 100644 --- a/configs/airootfs/root/.automated_script.sh +++ b/configs/airootfs/root/.automated_script.sh @@ -116,4 +116,4 @@ rm -f /run/omarchy-install/state.json --full-name-file /root/user_full_name.txt \ --email-file /root/user_email_address.txt \ --encrypt-file /root/user_encrypt_installation.txt \ - --ssh-keys-file /root/ssh.json + --authorized-keys-file /root/authorized_keys diff --git a/configs/airootfs/usr/local/bin/omarchy-cidata-load b/configs/airootfs/usr/local/bin/omarchy-cidata-load index 24189e2..28c4baa 100755 --- a/configs/airootfs/usr/local/bin/omarchy-cidata-load +++ b/configs/airootfs/usr/local/bin/omarchy-cidata-load @@ -38,15 +38,15 @@ mount -o ro "$device" "$mnt" || exit 1 loaded=1 if [[ -f $mnt/user_configuration.json && -f $mnt/user_credentials.json ]]; then files=("$mnt"/user_configuration.json "$mnt"/user_credentials.json) - for file in user_full_name.txt user_email_address.txt user_encrypt_installation.txt ssh.json; do + for file in user_full_name.txt user_email_address.txt user_encrypt_installation.txt authorized_keys; do [[ -f $mnt/$file ]] && files+=("$mnt/$file") done if cp "${files[@]}" "$dest"/; then loaded=0 else # A partial copy must not leave files behind: the wizard cleans up only - # what it writes itself, so a half-copied ssh.json would leak into the - # interactive install that follows. + # what it writes itself, so a half-copied authorized_keys would leak into + # the interactive install that follows. for file in "${files[@]}"; do rm -f "$dest/${file##*/}" done diff --git a/configs/airootfs/usr/local/bin/omarchy-iso-install b/configs/airootfs/usr/local/bin/omarchy-iso-install index 0380a1b..9f0c716 100755 --- a/configs/airootfs/usr/local/bin/omarchy-iso-install +++ b/configs/airootfs/usr/local/bin/omarchy-iso-install @@ -11,13 +11,13 @@ set -eEo pipefail # doesn't choke on flags it doesn't recognize. while [[ $# -gt 0 ]]; do case $1 in - --config) export OMARCHY_INSTALL_CONFIG="$2"; shift 2 ;; - --creds) export OMARCHY_INSTALL_CREDS="$2"; shift 2 ;; - --full-name-file) export OMARCHY_INSTALL_FULL_NAME_FILE="$2"; shift 2 ;; - --email-file) export OMARCHY_INSTALL_EMAIL_FILE="$2"; shift 2 ;; - --encrypt-file) export OMARCHY_INSTALL_ENCRYPT_FILE="$2"; shift 2 ;; - --ssh-keys-file) export OMARCHY_INSTALL_SSH_KEYS_FILE="$2"; shift 2 ;; - *) echo "omarchy-iso-install: unknown arg $1" >&2; exit 2 ;; + --config) export OMARCHY_INSTALL_CONFIG="$2"; shift 2 ;; + --creds) export OMARCHY_INSTALL_CREDS="$2"; shift 2 ;; + --full-name-file) export OMARCHY_INSTALL_FULL_NAME_FILE="$2"; shift 2 ;; + --email-file) export OMARCHY_INSTALL_EMAIL_FILE="$2"; shift 2 ;; + --encrypt-file) export OMARCHY_INSTALL_ENCRYPT_FILE="$2"; shift 2 ;; + --authorized-keys-file) export OMARCHY_INSTALL_AUTHORIZED_KEYS_FILE="$2"; shift 2 ;; + *) echo "omarchy-iso-install: unknown arg $1" >&2; exit 2 ;; esac done diff --git a/configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py b/configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py index 34e8de9..5b6008b 100644 --- a/configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py +++ b/configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py @@ -18,7 +18,7 @@ class InstallContext: full_name: str email: str encrypt: bool - ssh_keys_path: Path | None + authorized_keys_path: Path | None user_configuration: dict user_credentials: dict @@ -60,7 +60,7 @@ class InstallContext: full_name=_read_text(os.environ.get("OMARCHY_INSTALL_FULL_NAME_FILE")), email=_read_text(os.environ.get("OMARCHY_INSTALL_EMAIL_FILE")), encrypt=_read_text(os.environ.get("OMARCHY_INSTALL_ENCRYPT_FILE")).lower() in ("true", "yes", "1"), - ssh_keys_path=_optional_path(os.environ.get("OMARCHY_INSTALL_SSH_KEYS_FILE")), + authorized_keys_path=_optional_path(os.environ.get("OMARCHY_INSTALL_AUTHORIZED_KEYS_FILE")), user_configuration=user_configuration, user_credentials=json.loads(creds_path.read_text()), arch_config_path=arch_config_path, @@ -107,8 +107,8 @@ def _default_omarchy_install(user_configuration: dict) -> dict[str, Any]: def _optional_path(path: str | None) -> Path | None: """A path the caller always passes but that need not exist. The live ISO - passes --ssh-keys-file on every install; only autoinstall drives put a file - there.""" + passes --authorized-keys-file on every install; only autoinstall drives put + a file there.""" if not path: return None p = Path(path) 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 4be987c..e391225 100644 --- a/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py +++ b/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py @@ -22,7 +22,6 @@ Phase ordering (full-disk and protected/pre-mounted): from __future__ import annotations import hashlib -import json import os import re import shutil @@ -1314,10 +1313,10 @@ def configure_login(ctx: InstallContext) -> None: # ───────────────────────────────────────────────────────────────────────────── def configure_ssh_access(ctx: InstallContext) -> None: - if ctx.ssh_keys_path is None: + if ctx.authorized_keys_path is None: return - keys = _authorized_keys(ctx.ssh_keys_path) + keys = _authorized_keys(ctx.authorized_keys_path) info(f"› installing {len(keys)} SSH key(s) for {ctx.username}") ssh_dir = ctx.target / "home" / ctx.username / ".ssh" @@ -1362,21 +1361,20 @@ def configure_ssh_access(ctx: InstallContext) -> None: def _authorized_keys(path: Path) -> list[str]: - """Parse the autoinstall ssh.json: a JSON array of public key strings. + """Read the autoinstall authorized_keys: sshd's own format, one public key + per line, with blank lines and # comments dropped. Raise rather than skip on anything unusable. An install that "succeeds" into a machine nobody can log into is worse than one that stops with the reason on screen. """ try: - keys = json.loads(path.read_text()) - except (OSError, json.JSONDecodeError) as exc: - raise RuntimeError(f"{path} is not readable JSON: {exc}") from exc + lines = path.read_text().splitlines() + except OSError as exc: + raise RuntimeError(f"{path} is not readable: {exc}") from exc - if not isinstance(keys, list) or not all(isinstance(key, str) for key in keys): - raise RuntimeError(f"{path} must be a JSON array of public key strings") - - keys = [key.strip() for key in keys if key.strip()] + keys = [line.strip() for line in lines] + keys = [key for key in keys if key and not key.startswith("#")] if not keys: raise RuntimeError(f"{path} contains no SSH keys") diff --git a/plans/autoinstall.md b/plans/autoinstall.md index ed318d2..4093dd1 100644 --- a/plans/autoinstall.md +++ b/plans/autoinstall.md @@ -68,11 +68,11 @@ Do not take `manifests/fresh-4-semantic.json` as the authority on any of this. I - Look for `/dev/disk/by-label/cidata` then `/dev/disk/by-label/CIDATA`; exit non-zero if neither exists. - Mount read-only on a temp mountpoint; exit non-zero if the mount fails. - Require both `user_configuration.json` and `user_credentials.json` on the drive — this is the same pair `InstallContext.from_env` treats as mandatory. Missing either means the drive is not an autoinstall drive; unmount and exit non-zero. - - Copy the required pair plus, when present, `user_full_name.txt`, `user_email_address.txt`, `user_encrypt_installation.txt`, and `ssh.json` into `/root` with a single `cp`, and exit non-zero if the copy fails — a "loaded" drive with missing inputs would fail the install later with a worse error. + - Copy the required pair plus, when present, `user_full_name.txt`, `user_email_address.txt`, `user_encrypt_installation.txt`, and `authorized_keys` into `/root` with a single `cp`, and exit non-zero if the copy fails — a "loaded" drive with missing inputs would fail the install later with a worse error. - Unmount before exiting. Leave nothing mounted for the install to trip over. - The script takes an optional path prefix so the unit tests can point it at a sandbox; production callers pass nothing. - Replace the bare `./configurator` call in `.automated_script.sh` with: run `omarchy-cidata-load`; on success export `OMARCHY_UI_INTERACTIVE=no`, on failure run `./configurator`. -- Pass `--ssh-keys-file /root/ssh.json` to `omarchy-iso-install` in the dashboard invocation, alongside the existing five flags. Always pass it; the orchestrator no-ops when the file is absent, which keeps the interactive path on the identical command line. +- Pass `--authorized-keys-file /root/authorized_keys` to `omarchy-iso-install` in the dashboard invocation, alongside the existing five flags. Always pass it; the orchestrator no-ops when the file is absent, which keeps the interactive path on the identical command line. ### Notes - The three text files are optional by construction: `_read_text` in `context.py` returns `""` for a missing path and `encrypt` falls back to false. Do not add stricter checks here than the orchestrator itself applies. @@ -116,23 +116,23 @@ The dashboard is the only process that owns the visible UI. Suppressing its prom - `configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py` ### Changes -- `omarchy-iso-install`: add `--ssh-keys-file` to the arg loop, exporting `OMARCHY_INSTALL_SSH_KEYS_FILE`. Same shape as the five existing flags, ahead of the `unknown arg` catch-all (`:19`). -- `context.py`: add an `ssh_keys_path: Path | None` field populated from that variable in `from_env`, `None` when unset or when the file does not exist. +- `omarchy-iso-install`: add `--authorized-keys-file` to the arg loop, exporting `OMARCHY_INSTALL_AUTHORIZED_KEYS_FILE`. Same shape as the five existing flags, ahead of the `unknown arg` catch-all (`:19`). +- `context.py`: add an `authorized_keys_path: Path | None` field populated from that variable in `from_env`, `None` when unset or when the file does not exist. - `phases_impl.py`: add `configure_ssh_access(ctx)`: - - Return immediately when `ctx.ssh_keys_path` is `None`. - - Parse the file as a JSON array of public key strings; write them one per line to `/home//.ssh/authorized_keys`. + - Return immediately when `ctx.authorized_keys_path` is `None`. + - Read the file as sshd's own authorized_keys format, dropping blank lines and # comments; write the keys one per line to `/home//.ssh/authorized_keys`. - `.ssh` at `0700`, `authorized_keys` at `0600`, both owned by the user via `arch-chroot chown -R :`. Do not hardcode uid 1000 as PR #72 did — ask the target for it. - `arch-chroot systemctl enable sshd.service`. Works cleanly in the chroot. - `arch-chroot ufw allow ssh`. This one exits **non-zero** in a chroot (`ERROR: problem running`) because ufw cannot reach netfilter, but it writes the rule to `/etc/ufw/user.rules` before failing, and that file is what `ufw.service` loads on first boot. So ignore the exit status and assert the rule landed in the file instead. No ufw-presence check: ufw is part of Omarchy's default package set, and the rule assertion already fails loudly if it is somehow missing. - - A malformed or empty `ssh.json` must fail the phase loudly. A machine that installs "successfully" and is then unreachable is worse than one that stops with an error on screen. + - An `authorized_keys` with no usable keys must fail the phase loudly. A machine that installs "successfully" and is then unreachable is worse than one that stops with an error on screen. - `main.py`: register `("Configuring SSH access", configure_ssh_access)` in `build_phases` after `configure_login` (`:52`) and before `validate_boot`. ### Model to follow `configure_login` (`phases_impl.py:1251`) — direct writes against `ctx.target`, `arch-chroot` reserved for ownership and `systemctl`. Match its structure. ### Acceptance criteria -- Install with `ssh.json`: `ssh @` works with the corresponding private key on first boot, no password. -- Install without `ssh.json`: phase runs, does nothing, install unaffected; `~/.ssh` is not created. +- Install with `authorized_keys`: `ssh @` works with the corresponding private key on first boot, no password. +- Install without `authorized_keys`: phase runs, does nothing, install unaffected; `~/.ssh` is not created. - `authorized_keys` is `0600` and owned by the install user, not root. - Port 22 is open in ufw on the installed system. Test this by connecting, not by reading the phase log — the rule is written from a chroot that cannot verify it. - Password SSH auth is left at the distro default — autoinstall does not enable it. @@ -147,7 +147,7 @@ The dashboard is the only process that owns the visible UI. Suppressing its prom ### Changes - New "Autoinstall" section covering: what the `cidata` drive is, the file table, how to produce the drive, and a Proxmox `qm create` example with disk-first boot order so the empty disk falls through to the ISO on the first boot and boots the installed system afterward. -- Show `openssl passwd -6` for the credential hash and the `ssh.json` array format. +- Show `openssl passwd -6` for the credential hash and the `authorized_keys` format. - State plainly that the config files are the configurator's own output — the documented way to get a starting `user_configuration.json` is to run one interactive install and copy what it wrote. - Do not repeat PR #72's networking claims. NetworkManager comes enabled with DHCP on a stock install; sshd ships disabled, which is why the SSH phase exists. @@ -168,7 +168,7 @@ The dashboard is the only process that owns the visible UI. Suppressing its prom ### Negative tests - Drive labeled `cidata` with no files → configurator runs. - Drive with `user_configuration.json` only → configurator runs. -- Malformed `ssh.json` → install stops in the remote-access phase with a visible error, does not reboot into an unreachable machine. +- An `authorized_keys` with no usable keys → install stops in the remote-access phase with a visible error, does not reboot into an unreachable machine. - Install failure under autoinstall (e.g. a disk target that does not exist) → failure screen renders, process exits non-zero, no hang. ### Regression surface diff --git a/test/cidata-load-test.sh b/test/cidata-load-test.sh index ede60fa..ae5cee1 100644 --- a/test/cidata-load-test.sh +++ b/test/cidata-load-test.sh @@ -88,9 +88,9 @@ write_required_pair echo "Jeff" >"$sandbox/media/user_full_name.txt" echo "jeff@example.com" >"$sandbox/media/user_email_address.txt" echo "false" >"$sandbox/media/user_encrypt_installation.txt" -echo '["ssh-ed25519 AAAA jeff@host"]' >"$sandbox/media/ssh.json" +echo 'ssh-ed25519 AAAA jeff@host' >"$sandbox/media/authorized_keys" run_load || fail "full file set loads" -for file in user_configuration.json user_credentials.json user_full_name.txt user_email_address.txt user_encrypt_installation.txt ssh.json; do +for file in user_configuration.json user_credentials.json user_full_name.txt user_email_address.txt user_encrypt_installation.txt authorized_keys; do [[ -f $sandbox/root/$file ]] || fail "full file set copies $file" done grep -q '^umount ' "$TEST_LOG" || fail "full file set unmounts the drive" @@ -109,16 +109,16 @@ new_sandbox attach_drive cidata write_required_pair run_load || fail "required pair alone loads" -[[ ! -e $sandbox/root/ssh.json ]] || fail "required pair alone copies no optional files" +[[ ! -e $sandbox/root/authorized_keys ]] || fail "required pair alone copies no optional files" pass "required pair alone loads without optional files" # Optional files are copied individually when present. new_sandbox attach_drive cidata write_required_pair -echo '["ssh-ed25519 AAAA jeff@host"]' >"$sandbox/media/ssh.json" -run_load || fail "required pair plus ssh.json loads" -[[ -f $sandbox/root/ssh.json ]] || fail "ssh.json is copied when present" +echo 'ssh-ed25519 AAAA jeff@host' >"$sandbox/media/authorized_keys" +run_load || fail "required pair plus authorized_keys loads" +[[ -f $sandbox/root/authorized_keys ]] || fail "authorized_keys is copied when present" [[ ! -e $sandbox/root/user_full_name.txt ]] || fail "absent optional files are not copied" pass "present optional files are copied, absent ones skipped" @@ -162,9 +162,9 @@ pass "copy failure falls back and unmounts" new_sandbox attach_drive cidata write_required_pair -echo '["ssh-ed25519 AAAA jeff@host"]' >"$sandbox/media/ssh.json" +echo 'ssh-ed25519 AAAA jeff@host' >"$sandbox/media/authorized_keys" mkdir "$sandbox/root/user_credentials.json" ! run_load 2>/dev/null || fail "partial copy exits non-zero" [[ ! -e $sandbox/root/user_configuration.json ]] || fail "partial copy removes what it copied" -[[ ! -e $sandbox/root/ssh.json ]] || fail "partial copy leaves no ssh.json behind" +[[ ! -e $sandbox/root/authorized_keys ]] || fail "partial copy leaves no authorized_keys behind" pass "partial copy cleans up what it copied" diff --git a/test/test_configure_ssh_access.py b/test/test_configure_ssh_access.py index 71159af..812761e 100644 --- a/test/test_configure_ssh_access.py +++ b/test/test_configure_ssh_access.py @@ -5,7 +5,6 @@ the tests run it against a temp directory with subprocess.run recorded and the ufw side effect (writing user.rules) simulated by the fake. """ -import json import stat import sys import tempfile @@ -56,12 +55,12 @@ class ConfigureSshAccessTest(unittest.TestCase): return CompletedProcess(cmd, 1) return CompletedProcess(cmd, 0) - def ctx(self, ssh_keys=None, ssh_keys_text=None): - ssh_keys_path = None - if ssh_keys is not None or ssh_keys_text is not None: - ssh_keys_path = self.target / "ssh.json" - ssh_keys_path.write_text(ssh_keys_text if ssh_keys_text is not None else json.dumps(ssh_keys)) - return types.SimpleNamespace(target=self.target, username="jeff", ssh_keys_path=ssh_keys_path) + def ctx(self, authorized_keys=None): + authorized_keys_path = None + if authorized_keys is not None: + authorized_keys_path = self.target / "authorized_keys" + authorized_keys_path.write_text(authorized_keys) + return types.SimpleNamespace(target=self.target, username="jeff", authorized_keys_path=authorized_keys_path) def configure(self, **kwargs): phases_impl.configure_ssh_access(self.ctx(**kwargs)) @@ -72,42 +71,47 @@ class ConfigureSshAccessTest(unittest.TestCase): def chrooted(self, program): return [cmd for cmd in self.calls if cmd[:2] == ["arch-chroot", str(self.target)] and cmd[2] == program] - def test_no_ssh_json_is_a_no_op(self): + def test_no_authorized_keys_is_a_no_op(self): self.configure() self.assertEqual(self.calls, []) self.assertFalse((self.target / "home" / "jeff" / ".ssh").exists()) - def test_writes_keys_one_per_line(self): - self.configure(ssh_keys=["ssh-ed25519 AAAA jeff@host", "ssh-rsa BBBB jeff@work"]) + def test_installs_keys_one_per_line(self): + self.configure(authorized_keys="ssh-ed25519 AAAA jeff@host\nssh-rsa BBBB jeff@work\n") self.assertEqual(self.authorized_keys().read_text(), "ssh-ed25519 AAAA jeff@host\nssh-rsa BBBB jeff@work\n") - def test_strips_whitespace_and_drops_blank_entries(self): - self.configure(ssh_keys=[" ssh-ed25519 AAAA jeff@host\n", "", " "]) + def test_drops_blank_lines_and_comments(self): + self.configure(authorized_keys="# work laptop\n\n ssh-ed25519 AAAA jeff@host \n") self.assertEqual(self.authorized_keys().read_text(), "ssh-ed25519 AAAA jeff@host\n") + def test_keys_with_options_pass_through(self): + key = 'command="/usr/bin/true",no-pty ssh-ed25519 AAAA jeff@host' + self.configure(authorized_keys=f"{key}\n") + self.assertEqual(self.authorized_keys().read_text(), f"{key}\n") + def test_ssh_dir_is_private(self): - self.configure(ssh_keys=["ssh-ed25519 AAAA jeff@host"]) + self.configure(authorized_keys="ssh-ed25519 AAAA jeff@host\n") mode = stat.S_IMODE((self.target / "home" / "jeff" / ".ssh").stat().st_mode) self.assertEqual(mode, 0o700) def test_authorized_keys_is_private(self): - self.configure(ssh_keys=["ssh-ed25519 AAAA jeff@host"]) + self.configure(authorized_keys="ssh-ed25519 AAAA jeff@host\n") self.assertEqual(stat.S_IMODE(self.authorized_keys().stat().st_mode), 0o600) def test_chowns_ssh_dir_to_the_user(self): - self.configure(ssh_keys=["ssh-ed25519 AAAA jeff@host"]) + self.configure(authorized_keys="ssh-ed25519 AAAA jeff@host\n") self.assertEqual(self.chrooted("chown"), [ ["arch-chroot", str(self.target), "chown", "-R", "jeff:jeff", "/home/jeff/.ssh"], ]) def test_enables_sshd(self): - self.configure(ssh_keys=["ssh-ed25519 AAAA jeff@host"]) + self.configure(authorized_keys="ssh-ed25519 AAAA jeff@host\n") self.assertEqual(self.chrooted("systemctl"), [ ["arch-chroot", str(self.target), "systemctl", "enable", "sshd.service"], ]) def test_allows_ssh_through_ufw_despite_chroot_exit_status(self): - self.configure(ssh_keys=["ssh-ed25519 AAAA jeff@host"]) + self.configure(authorized_keys="ssh-ed25519 AAAA jeff@host\n") self.assertEqual(self.chrooted("ufw"), [ ["arch-chroot", str(self.target), "ufw", "allow", "ssh"], ]) @@ -115,27 +119,19 @@ class ConfigureSshAccessTest(unittest.TestCase): def test_fails_when_ufw_does_not_record_the_rule(self): self.ufw_writes_rule = False with self.assertRaisesRegex(RuntimeError, "allow rule for port 22"): - self.configure(ssh_keys=["ssh-ed25519 AAAA jeff@host"]) + self.configure(authorized_keys="ssh-ed25519 AAAA jeff@host\n") - def test_malformed_json_fails_the_phase(self): - with self.assertRaisesRegex(RuntimeError, "not readable JSON"): - self.configure(ssh_keys_text="not json") - - def test_non_array_json_fails_the_phase(self): - with self.assertRaisesRegex(RuntimeError, "JSON array of public key strings"): - self.configure(ssh_keys={"key": "ssh-ed25519 AAAA"}) - - def test_non_string_entries_fail_the_phase(self): - with self.assertRaisesRegex(RuntimeError, "JSON array of public key strings"): - self.configure(ssh_keys=["ssh-ed25519 AAAA jeff@host", 42]) - - def test_empty_key_list_fails_the_phase(self): + def test_empty_file_fails_the_phase(self): with self.assertRaisesRegex(RuntimeError, "contains no SSH keys"): - self.configure(ssh_keys=[]) + self.configure(authorized_keys="") - def test_blank_only_key_list_fails_the_phase(self): + def test_blank_only_file_fails_the_phase(self): with self.assertRaisesRegex(RuntimeError, "contains no SSH keys"): - self.configure(ssh_keys=["", " "]) + self.configure(authorized_keys="\n \n") + + def test_comment_only_file_fails_the_phase(self): + with self.assertRaisesRegex(RuntimeError, "contains no SSH keys"): + self.configure(authorized_keys="# no keys here\n") class OptionalPathTest(unittest.TestCase): @@ -144,7 +140,7 @@ class OptionalPathTest(unittest.TestCase): self.assertIsNone(_optional_path(None)) self.assertIsNone(_optional_path("")) - self.assertIsNone(_optional_path("/does/not/exist/ssh.json")) + self.assertIsNone(_optional_path("/does/not/exist/authorized_keys")) def test_existing_path_is_returned(self): from orchestrator.context import _optional_path