diff --git a/README.md b/README.md index 1ec23ea0..86f1b35e 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ These are the configurator's own output files, so the way to get a starting set | File | Required | Purpose | |------|----------|---------| | `user_configuration.json` | Yes | archinstall config: disk, hostname, timezone, keyboard | -| `user_credentials.json` | Yes | Username and password hash | +| `user_credentials.json` | Yes | Username and password hash; on a child install also the parent hash as `root_enc_password`, `sudo: false`, and `parent_encryption_password` when encrypted | | `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 | @@ -50,6 +50,8 @@ These are the configurator's own output files, so the way to get a starting set 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"`. +A child install (Omarchy's kids mode, what the interactive wizard's first question calls _Child_) is selected by `"profile": "child"` inside `omarchy_install` in `user_configuration.json`. Its credentials carry two passwords: the user's hash is the kid password, `root_enc_password` is the parent password's hash, the user has `"sudo": false` so it stays out of `wheel`, and on an encrypted install `encryption_password` (the kid's) formats the disk while `parent_encryption_password` is added as a second key. The installed system records the profile in `/etc/omarchy/profile` and keeps root for the parent through `omarchy-parent`. + 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. `authorized_keys` is the same file sshd reads — copy your own or write one key per line: diff --git a/bin/omarchy-iso-test b/bin/omarchy-iso-test index 8a0677f1..a76b301a 100755 --- a/bin/omarchy-iso-test +++ b/bin/omarchy-iso-test @@ -14,6 +14,12 @@ # Usage: omarchy-iso-test [release/omarchy.iso] [options] # # --encrypt Drive the encrypted install flow (default: unencrypted) +# --child Answer "Child" at the first question: a kid password and +# a parent password, sudo on the parent one. The run then +# proves the parent password opens the disk and the login +# screen into the kid's session, and bootstraps SSH from +# the desktop's terminal, since child installs close the +# text consoles. # --provision Drive the deferred-provisioning install flow: no user during install, the # first boot runs omarchy-provision-owner and creates it there # --reuse-base Skip the install phase if a base image already exists @@ -49,11 +55,15 @@ SYNC_ALL=false GUEST_USER="omarchy" GUEST_PASSWORD="omarchy" +# A child run types this at the parent prompts; it is then what sudo asks for. +GUEST_PARENT_PASSWORD="omarchy-parent" +CHILD=false GUEST_HOSTNAME="omarchy-test" while (($#)); do case "$1" in --encrypt) ENCRYPT=true ;; + --child) CHILD=true ;; --provision) PROVISION=true ;; --reuse-base) REUSE_BASE=true ;; --install-only) INSTALL_ONLY=true ;; @@ -70,6 +80,31 @@ while (($#)); do shift done +# A child run logs in with the parent password, at the disk and at SDDM: it +# must land in the kid's session (the in-guest suite then checks whose it is), +# and the kid's own password is exercised at the installer and by sudo's +# refusal of it. A "Me" run logs in with the one password there is. +if $CHILD; then + GUEST_LOGIN_PASSWORD="$GUEST_PARENT_PASSWORD" +else + GUEST_LOGIN_PASSWORD="$GUEST_PASSWORD" +fi + +# sudo asks for the parent password on a child install and the account's own +# otherwise; every privileged call the harness makes goes through this one. +if $CHILD; then + GUEST_SUDO_PASSWORD="$GUEST_PARENT_PASSWORD" +else + GUEST_SUDO_PASSWORD="$GUEST_PASSWORD" +fi + +# "Another owner" installs the default profile, so a child run cannot also +# defer provisioning. +if $CHILD && $PROVISION; then + echo "--child cannot be combined with --provision" >&2 + exit 2 +fi + if [[ -z $SYNC_DIR && -f ${OMARCHY_PATH:-}/test/acceptance ]]; then SYNC_DIR="$OMARCHY_PATH" fi @@ -139,7 +174,7 @@ capture_console() { stop_vm() { vm_running || return 0 - if ! ssh_guest "echo $GUEST_PASSWORD | sudo -S systemctl poweroff" >/dev/null 2>&1; then + if ! ssh_guest "echo $GUEST_SUDO_PASSWORD | sudo -S systemctl poweroff" >/dev/null 2>&1; then qmp '"system_powerdown"' >/dev/null fi @@ -618,16 +653,16 @@ drive_configurator() { capture_console "success-installer-00-greeter" press ret - # Keyboard is the first screen. Ctrl+C there is the hidden entry into deferred provisioning - # mode; a normal install just selects the layout. - wait_for_screen "keyboard layout" 300 - capture_console "success-installer-01-keyboard-layout" + # "Who is this computer for?" is the first screen: Me is preselected, Child + # one row down, Another owner two. + wait_for_screen "computer for" 300 + capture_console "success-installer-01-computer-for" if $PROVISION; then - press ctrl-c # arm "prepare for another owner" - wait_for_screen "another owner" 30 - capture_console "success-installer-02-prepare-confirm" - press ret # "Yes, prepare for another owner" is the affirmative default + press down + press down + capture_console "success-installer-02-another-owner" + press ret # Jumps straight to disk selection, then the overwrite confirm. wait_for_screen "install disk" 60 @@ -651,6 +686,14 @@ drive_configurator() { return 0 fi + if $CHILD; then + press down + capture_console "success-installer-01-computer-for-child" + fi + press ret + + wait_for_screen "keyboard layout" 120 + capture_console "success-installer-02-keyboard-layout" press ret # English (US) is preselected # Normal flow: user step comes before disk selection. @@ -669,15 +712,32 @@ drive_configurator() { capture_console "success-installer-04-password-confirmation" press ret - wait_for_screen "Full name" 60 - type_text "Omarchy Test" - capture_console "success-installer-05-full-name" - press ret + if $CHILD; then + # The parent password has its own screen; its confirm names it, which tells + # it apart from the kid's confirm that was on screen a moment before. + wait_for_screen "Parent password" 60 + type_text "$GUEST_PARENT_PASSWORD" + capture_console "success-installer-04a-parent-password" + press ret - wait_for_screen "Email address" 60 - type_text "test@omarchy.org" - capture_console "success-installer-06-email" - press ret + wait_for_screen "Must match the parent" 60 + type_text "$GUEST_PARENT_PASSWORD" + capture_console "success-installer-04b-parent-password-confirmation" + press ret + fi + + # A child install asks for neither name nor email. + if ! $CHILD; then + wait_for_screen "Full name" 60 + type_text "Omarchy Test" + capture_console "success-installer-05-full-name" + press ret + + wait_for_screen "Email address" 60 + type_text "test@omarchy.org" + capture_console "success-installer-06-email" + press ret + fi wait_for_screen "Hostname" 60 type_text "$GUEST_HOSTNAME" @@ -754,19 +814,34 @@ drive_provision_owner() { type_text "$GUEST_PASSWORD" press ret - wait_for_screen "Full name" 60 - type_text "Omarchy Test" - press ret + if $CHILD; then + wait_for_screen "Parent password" 60 + type_text "$GUEST_PARENT_PASSWORD" + capture_console "success-provision-03a-parent-password" + press ret - wait_for_screen "Email address" 60 - # The keyboard step above applied English (UK), where '@' is Shift+' (not - # Shift+2 as on US); send that keycode so the typed email is correct under - # the new layout — itself further proof the deferred keymap took effect. - type_text "test" - press shift-apostrophe - type_text "omarchy.org" - capture_console "success-provision-04-email" - press ret + wait_for_screen "Must match the parent" 60 + type_text "$GUEST_PARENT_PASSWORD" + press ret + fi + + # A child install asks for neither name nor email, so a child run goes + # without the '@' keymap proof below. + if ! $CHILD; then + wait_for_screen "Full name" 60 + type_text "Omarchy Test" + press ret + + wait_for_screen "Email address" 60 + # The keyboard step above applied English (UK), where '@' is Shift+' (not + # Shift+2 as on US); send that keycode so the typed email is correct under + # the new layout — itself further proof the deferred keymap took effect. + type_text "test" + press shift-apostrophe + type_text "omarchy.org" + capture_console "success-provision-04-email" + press ret + fi # Hostname is deferred to first boot too. wait_for_screen "Hostname" 60 @@ -846,12 +921,12 @@ wait_for_install() { unlock_luks() { log "Typing the LUKS passphrase until boot proceeds" - local waited=0 + local waited=0 screen sleep 10 capture_console "success-first-boot-01-luks-prompt" while true; do - type_text "$GUEST_PASSWORD" + type_text "$GUEST_LOGIN_PASSWORD" if ((waited == 0)); then capture_console "success-first-boot-02-luks-passphrase" fi @@ -859,10 +934,21 @@ unlock_luks() { sleep 20 ((waited += 30)) - press ctrl-alt-f3 - sleep 3 - if ocr_screen | grep -qi "login:"; then - return 0 + if $CHILD; then + # Child installs close the text consoles, so the sign that the disk + # opened is the passphrase prompt leaving the screen. ocr_screen says + # nothing at all when the screendump or the conversion fails, which + # would otherwise read as a prompt that has gone. + screen=$(ocr_screen) + if [[ -n $screen ]] && ! grep -qi "passphrase" <<<"$screen"; then + return 0 + fi + else + press ctrl-alt-f3 + sleep 3 + if ocr_screen | grep -qi "login:"; then + return 0 + fi fi if ((waited >= 300)); then @@ -876,6 +962,11 @@ unlock_luks() { # Log into a spare console TTY as the user and authorize the harness SSH key — # the same thing a person would do to give themselves remote access. bootstrap_ssh() { + if $CHILD; then + bootstrap_ssh_desktop + return + fi + log "Authorizing SSH access via console login" mkdir -p "$BASE_DIR/www" @@ -883,8 +974,8 @@ bootstrap_ssh() { mkdir -p ~/.ssh && chmod 700 ~/.ssh echo "$(cat "$SSH_KEY.pub")" >>~/.ssh/authorized_keys chmod 600 ~/.ssh/authorized_keys -echo "$GUEST_PASSWORD" | sudo -S ufw allow from 10.0.2.2 to any port 22 proto tcp -echo "$GUEST_PASSWORD" | sudo -S systemctl enable --now sshd.service +echo "$GUEST_SUDO_PASSWORD" | sudo -S ufw allow from 10.0.2.2 to any port 22 proto tcp +echo "$GUEST_SUDO_PASSWORD" | sudo -S systemctl enable --now sshd.service EOF (cd "$BASE_DIR/www" && exec python3 -m http.server "$HTTP_PORT" --bind 127.0.0.1 >/dev/null 2>&1) & @@ -925,6 +1016,63 @@ EOF return 1 } +# A child install closes the text consoles, so the harness does what a parent +# would: log in at SDDM with the parent password (an encrypted install has +# autologged in after the disk), open the terminal with Super+Return, and run +# the bootstrap there. SSH coming up as the kid is the proof that the parent +# password opened the kid's session. +bootstrap_ssh_desktop() { + log "Authorizing SSH access from the desktop (the parent password at the login screen)" + + mkdir -p "$BASE_DIR/www" + cat >"$BASE_DIR/www/bootstrap" <>~/.ssh/authorized_keys +chmod 600 ~/.ssh/authorized_keys +echo "$GUEST_SUDO_PASSWORD" | sudo -S ufw allow from 10.0.2.2 to any port 22 proto tcp +echo "$GUEST_SUDO_PASSWORD" | sudo -S systemctl enable --now sshd.service +EOF + + (cd "$BASE_DIR/www" && exec python3 -m http.server "$HTTP_PORT" --bind 127.0.0.1 >/dev/null 2>&1) & + HTTP_PID=$! + + sleep 30 # first boot reaches SDDM, or the desktop on an encrypted install + local attempt + for attempt in 1 2 3; do + if ! $ENCRYPT; then + capture_console "success-first-boot-03-login-screen" + type_text "$GUEST_LOGIN_PASSWORD" + press ret + sleep 25 + fi + capture_console "success-first-boot-04-desktop" + press meta_l-ret # the terminal + sleep 8 + type_text "curl -fsS http://10.0.2.2:$HTTP_PORT/bootstrap -o /tmp/bs && bash /tmp/bs && exit" + capture_console "success-first-boot-05-bootstrap-command" + press ret + + if wait_for_ssh 120 "failure-first-boot-ssh-timeout-$attempt"; then + capture_console "success-first-boot-06-bootstrap-complete" + kill "$HTTP_PID" 2>/dev/null || true + HTTP_PID="" + local who + who=$(ssh_guest whoami 2>/dev/null | tr -d '\r\n') || who="" + if [[ $who != "$GUEST_USER" ]]; then + echo "The parent password opened a session for '${who:-}', expected the kid account '$GUEST_USER'" >&2 + return 1 + fi + log "the parent password opened the kid's session ($who)" + return 0 + fi + press esc + sleep 5 + done + + echo "Desktop bootstrap did not produce SSH access after 3 attempts" >&2 + return 1 +} + # Get a freshly booted system to a running Hyprland session, typing at the # LUKS prompt and/or SDDM greeter exactly like a user would. establish_session() { @@ -933,7 +1081,7 @@ establish_session() { sleep 10 capture_console "success-session-01-luks-prompt" until ssh_guest true 2>/dev/null; do - type_text "$GUEST_PASSWORD" + type_text "$GUEST_LOGIN_PASSWORD" if ((waited == 0)); then capture_console "success-session-02-luks-passphrase" fi @@ -960,7 +1108,7 @@ establish_session() { if ((waited == 0)); then capture_console "success-session-01-login" fi - type_text "$GUEST_PASSWORD" + type_text "$GUEST_LOGIN_PASSWORD" if ((waited == 0)); then capture_console "success-session-02-password" fi @@ -1054,9 +1202,9 @@ install_phase() { capture_console "success-provision-09-handoff" fi - ssh_guest "echo $GUEST_PASSWORD | sudo -S cat /var/log/omarchy-install-timing.json 2>/dev/null" \ + ssh_guest "echo $GUEST_SUDO_PASSWORD | sudo -S cat /var/log/omarchy-install-timing.json 2>/dev/null" \ >"$RUN_DIR/omarchy-install-timing.json" 2>/dev/null || true - ssh_guest "echo $GUEST_PASSWORD | sudo -S cat /var/log/pacman.log 2>/dev/null" \ + ssh_guest "echo $GUEST_SUDO_PASSWORD | sudo -S cat /var/log/pacman.log 2>/dev/null" \ >"$RUN_DIR/pacman.log" 2>/dev/null || true log "Installed system is up. Saving base image." @@ -1101,11 +1249,12 @@ acceptance_phase() { # finished product, never a dev-linked checkout. log "Running acceptance suite" ssh_guest "OMARCHY_PATH=/usr/share/omarchy OMARCHY_ACCEPTANCE_DIR=/tmp/omarchy-acceptance \ - OMARCHY_ACCEPTANCE_SUDO_PASSWORD=$GUEST_PASSWORD bash .local/share/omarchy/test/acceptance" || status=$? + OMARCHY_ACCEPTANCE_SUDO_PASSWORD=$GUEST_SUDO_PASSWORD OMARCHY_ACCEPTANCE_USER_PASSWORD=$GUEST_PASSWORD \ + bash .local/share/omarchy/test/acceptance" || status=$? log "Collecting artifacts into $RUN_DIR" ssh_guest "tar -C /tmp -cf - omarchy-acceptance 2>/dev/null" | tar -C "$RUN_DIR" -xf - || true - ssh_guest "echo $GUEST_PASSWORD | sudo -S cat /var/log/omarchy-install.log 2>/dev/null" >"$RUN_DIR/omarchy-install.log" 2>/dev/null || true + ssh_guest "echo $GUEST_SUDO_PASSWORD | sudo -S cat /var/log/omarchy-install.log 2>/dev/null" >"$RUN_DIR/omarchy-install.log" 2>/dev/null || true if ((status == 0)); then capture_console "success-acceptance-final" else diff --git a/builder/build-iso.sh b/builder/build-iso.sh index db8daa2c..65e68469 100755 --- a/builder/build-iso.sh +++ b/builder/build-iso.sh @@ -140,6 +140,7 @@ sed -i -E '/^(linux|broadcom-wl)$/d' "$build_cache_dir/packages.x86_64" # pulls the published omarchy* from the network mirror like any other package. if [[ -d /omarchy-source ]]; then base_pkg_lists=(/omarchy-source/install/omarchy-base.packages /omarchy-source/install/omarchy-other.packages) + child_pkg_list=/omarchy-source/install/omarchy-child.packages setup_form=/omarchy-source/install/provisioning/setup-form.sh else # Pull the same package lists out of the freshly-downloaded Omarchy runtime @@ -162,12 +163,25 @@ else # actionable error below. bsdtar -xf "$omarchy_pkg" -C /tmp/omarchy-pkglists usr/share/omarchy/install/provisioning/setup-form.sh 2>/dev/null || true setup_form=/tmp/omarchy-pkglists/usr/share/omarchy/install/provisioning/setup-form.sh + # The child profile's list, tolerating a runtime that predates it the same + # way: a missing member must not abort the build. + bsdtar -xf "$omarchy_pkg" -C /tmp/omarchy-pkglists usr/share/omarchy/install/omarchy-child.packages 2>/dev/null || true + child_pkg_list=/tmp/omarchy-pkglists/usr/share/omarchy/install/omarchy-child.packages fi mkdir -p "$build_cache_dir/airootfs/usr/share/omarchy-iso" cp "${base_pkg_lists[0]}" "$build_cache_dir/airootfs/usr/share/omarchy-iso/omarchy-base.packages" cp "${base_pkg_lists[1]}" "$build_cache_dir/airootfs/usr/share/omarchy-iso/omarchy-other.packages" +# What a child install (kids mode) adds on top of the base list. The +# orchestrator reads the vendored copy, so a runtime without one gets an +# empty list rather than a missing file. +if [[ -f $child_pkg_list ]]; then + cp "$child_pkg_list" "$build_cache_dir/airootfs/usr/share/omarchy-iso/omarchy-child.packages" +else + echo "# No child package list in this runtime." >"$build_cache_dir/airootfs/usr/share/omarchy-iso/omarchy-child.packages" +fi + # The configurator's setup form comes from the runtime this ISO bundles, so the # installer and the first-boot setup that finishes a deferred install can never # disagree. A runtime predating the split ships no such file, which would leave @@ -193,6 +207,10 @@ mapfile -t all_packages < <( { cat "$build_cache_dir/packages.x86_64" grep -hv '^#\|^$' "${base_pkg_lists[@]}" + # The child list is comment-only until the child app set lands, and grep + # exits 1 when it selects nothing; under set -e that would end this group + # here and silently drop every list after it from the mirror. + grep -hv '^#\|^$' "$build_cache_dir/airootfs/usr/share/omarchy-iso/omarchy-child.packages" || true grep -hv '^#\|^$' /builder/archinstall.packages # Always include the selected Omarchy packages so the target install can # find the runtime and companion packages in the offline mirror. diff --git a/configs/airootfs/root/configurator b/configs/airootfs/root/configurator index 007b9331..4b53fdf4 100644 --- a/configs/airootfs/root/configurator +++ b/configs/airootfs/root/configurator @@ -201,34 +201,56 @@ notice() { echo } -# STEP 1: KEYBOARD LAYOUT +# STEP 0: WHO IS THIS COMPUTER FOR -keyboard_form() { - # Ctrl+C is the hidden entry into deferred provisioning, offered on every - # visit: the user step unwinds back here and nothing is on disk yet. Callers - # re-check defer_provisioning after this returns. +# The first question. Me and Child pick the install profile; Another owner +# defers every personal question to the machine's first boot (what Ctrl+C on +# the keyboard screen used to arm). Arrow keys work under any layout, so it +# can come before the keyboard is chosen. +computer_for_form() { local status while true; do clear_logo echo say "Let's setup your machine..." - say --foreground 8 "Press Ctrl+C to prepare this machine for another owner." echo - omarchy_prompt_keyboard && status=0 || status=$? + omarchy_prompt_computer_for && status=0 || status=$? ((status == 0)) && break # Esc means "back", and nothing precedes the first screen, so re-ask. ((status == OMARCHY_FORM_BACK)) && continue + abort + done - if ((status == OMARCHY_FORM_SIGNAL)); then - if confirm_prepare_for_another_owner; then - defer_provisioning=true - return 0 - fi - continue # declined: back to the keyboard picker - fi + case $computer_for in + child) + profile="child" + child_install=true + ;; + other) + defer_provisioning=true + ;; + esac +} + +# STEP 1: KEYBOARD LAYOUT + +keyboard_form() { + local status + + while true; do + clear_logo + echo + say "Let's setup your keyboard..." + echo + + omarchy_prompt_keyboard && status=0 || status=$? + ((status == 0)) && break + + # Esc means "back", and the first question is a one-time choice, so re-ask. + ((status == OMARCHY_FORM_BACK)) && continue abort done @@ -239,19 +261,6 @@ keyboard_form() { fi } -# Ctrl+C on the first screen offers to prepare the machine for another owner. -# Confirm it before committing, so an accidental Ctrl+C doesn't silently switch -# the whole install. -confirm_prepare_for_another_owner() { - clear_logo - echo - say "This prepares the machine for another owner." - say --foreground 8 "The system installs now, but setup is delayed until first boot." - echo - gum confirm --affirmative "Yes, prepare for another owner" --negative "No, keep setting up" \ - "Prepare this machine for another owner?" -} - # The user step. Deferred-provisioning installs skip it entirely — the machine's first owner # picks their keyboard and creates their user in the first-boot setup, so the # operator sets nothing user-specific. @@ -263,40 +272,75 @@ user_form() { # (Ctrl+C). Both non-zero cases unwind to user_step, which decides what they # mean here; the form itself never aborts. omarchy_prompt_username || return $? - omarchy_prompt_password || return $? - # Hash the password with SHA-512 crypt ($6$), which is what openssl passwd offers - password_hash=$(printf '%s' "$password" | openssl passwd -6 -stdin) + if $child_install; then + # A child install: the kid password logs in, unlocks, and opens the disk; + # the parent password is root's, what sudo and system prompts ask for, and + # opens the disk too so a parent can always get in. + omarchy_prompt_password kid || return $? + password_hash=$(printf '%s' "$password" | openssl passwd -6 -stdin) + + step "Let's set the parent password..." + say "sudo, updates, installs, and system prompts ask for this one; keep it from the kid." + echo + omarchy_prompt_parent_password || return $? + parent_password_hash=$(printf '%s' "$parent_password" | openssl passwd -6 -stdin) + # The parent-password header would otherwise sit over the screens that + # follow, which are about the account again. + step "Let's finish your user account..." + else + omarchy_prompt_password || return $? - omarchy_prompt_identity || return $? + # Hash the password with SHA-512 crypt ($6$), which is what openssl passwd offers + password_hash=$(printf '%s' "$password" | openssl passwd -6 -stdin) + fi + + # Full name and email only feed git identity and the compose shortcuts, and + # it is a parent at the keyboard on a child install, so an answer would be + # theirs: a child install asks for neither. + if $child_install; then + full_name="" + email_address="" + else + omarchy_prompt_identity || return $? + fi omarchy_prompt_hostname || return $? omarchy_prompt_timezone || return $? } -# Returns 0 on confirmed account details, or as soon as the keyboard screen -# arms deferred provisioning. +# Returns 0 on confirmed account details. user_step() { local status + # Fixed-width masks: one asterisk per character would put the passwords' + # lengths on the screen. + local mask="********" password_label="Password" parent_row="" + if $child_install; then + password_label="Kid password" + parent_row="Parent password,$mask"$'\n' + fi while true; do user_form && status=0 || status=$? if ((status != 0)); then # Esc unwinds to the start of the form. Ctrl+C has no side channel in the - # user step (only the keyboard and disk screens give it a meaning), so it - # keeps its long-standing behaviour of ending the install. + # user step (only the disk screen gives it a meaning), so it keeps its + # long-standing behaviour of ending the install. ((status == OMARCHY_FORM_BACK)) || abort keyboard_form - $defer_provisioning && return 0 continue fi + # The name and email rows, unless the form never asked (a child install). + local identity_rows="" + if ! $child_install; then + identity_rows="Full name,${full_name:-[Skipped]}"$'\n'"Email address,${email_address:-[Skipped]}"$'\n' + fi + # Add manual padding since gum table -p doesn't respect padding echo -e "Field,Value Username,$username -Password,$(printf "%${#password}s" | tr ' ' '*') -Full name,${full_name:-[Skipped]} -Email address,${email_address:-[Skipped]} -Hostname,$hostname +$password_label,$mask +${parent_row}${identity_rows}Hostname,$hostname Timezone,$timezone Keyboard,$keyboard" | gum table -s "," -p | sed "s/^/${PADDING_LEFT_SPACES}/" @@ -306,7 +350,6 @@ Keyboard,$keyboard" | break else keyboard_form - $defer_provisioning && return 0 fi done } @@ -458,6 +501,19 @@ _EOF_ password_hash_escaped=$(echo -n "$password_hash" | jq -Rsa) username_escaped=$(echo -n "$username" | jq -Rsa) + # On a child install root takes the parent hash, the kid account is created + # without sudo (so archinstall leaves wheel alone; omarchy-parent apply + # grants it sudo explicitly in the chroot), and the parent passphrase rides + # along so the orchestrator can add it as a second LUKS key. + local root_hash_escaped="$password_hash_escaped" sudo_flag="true" parent_encryption_line="" + if $child_install; then + root_hash_escaped=$(echo -n "$parent_password_hash" | jq -Rsa) + sudo_flag="false" + if [[ $encrypt_installation == true ]]; then + parent_encryption_line=" \"parent_encryption_password\": $(echo -n "$parent_password" | jq -Rsa)," + fi + fi + local credentials_encryption_line="" if [[ $encrypt_installation == true ]]; then credentials_encryption_line=" \"encryption_password\": $password_escaped," @@ -466,12 +522,13 @@ _EOF_ cat <<-_EOF_ >user_credentials.json { $credentials_encryption_line - "root_enc_password": $password_hash_escaped, +$parent_encryption_line + "root_enc_password": $root_hash_escaped, "users": [ { "enc_password": $password_hash_escaped, "groups": [], - "sudo": true, + "sudo": $sudo_flag, "username": $username_escaped } ] @@ -801,6 +858,7 @@ run_partition_execute() { "custom_commands": [], "omarchy_install": { "mode": "protected", + "profile": "$profile", "target_mount": "/mnt", "boot": { "esp_mount": "$esp_mount_in_target", @@ -987,8 +1045,8 @@ confirm_disk_overwrite() { # Decide what and how to install: sets install_target (full_disk|free_space), # install_target and encrypt_installation. Nothing is written to disk yet — # the user step runs between this and the execution below. (deferred provisioning never -# reaches here; it's armed by Ctrl+C on the keyboard screen and goes straight to -# disk selection.) +# reaches here; "Another owner" at the first question goes straight to disk +# selection.) select_installation() { local full_disk_only @@ -1030,9 +1088,13 @@ select_installation() { done } -# STEP 1: KEYBOARD (Ctrl+C here arms deferred provisioning and jumps to disk selection) +# STEP 0: WHO IS THIS COMPUTER FOR ("Another owner" jumps to disk selection) defer_provisioning=false +profile="default" +child_install=false +parent_password="" +parent_password_hash="" install_target="full_disk" # Let the live VT reach its settled width before the first screen so the logo @@ -1041,12 +1103,13 @@ wait_for_stable_terminal greeter -keyboard_form +computer_for_form -# Normal install: keyboard (loaded above, so the LUKS passphrase is typed under -# the right layout) → user → disk → install mode. The user step can unwind to -# the keyboard screen and arm deferral there, hence the second check. +# Normal install: keyboard (loaded first, so the LUKS passphrase is typed under +# the right layout) → user → disk → install mode. "Another owner" skips both +# personal steps. if ! $defer_provisioning; then + keyboard_form user_step fi @@ -1139,6 +1202,7 @@ cat <<-_EOF_ >user_configuration.json "custom_commands": [], "omarchy_install": { "mode": "full_disk", + "profile": "$profile", "defer_provisioning": $defer_provisioning, "target_mount": "/mnt", "boot": { diff --git a/configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py b/configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py index 9b5c50ff..1fe577db 100644 --- a/configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py +++ b/configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py @@ -12,6 +12,9 @@ from typing import Any +PROFILES = frozenset({"default", "child"}) + + @dataclass class InstallContext: config_path: Path @@ -27,6 +30,10 @@ class InstallContext: arch_config_path: Path omarchy_install: dict[str, Any] defer_provisioning: bool = False + # The install profile: "default", or "child" for kids mode, which keys + # the disk to a kid and a parent password and has the runtime keep root + # for the parent. Recorded on the target by omarchy-apply-system --profile. + profile: str = "default" target: Path = Path("/mnt") omarchy_path: Path = Path("/usr/share/omarchy") @@ -58,6 +65,11 @@ def from_env(cls) -> "InstallContext": defer_provisioning = bool(omarchy_install.get("defer_provisioning")) or defer_provisioning_marker is not None omarchy_install["defer_provisioning"] = defer_provisioning + profile = str(omarchy_install.get("profile") or "default") + if profile not in PROFILES: + raise RuntimeError(f"omarchy_install.profile must be one of {sorted(PROFILES)}, not {profile!r}") + omarchy_install["profile"] = profile + if creds_path.exists(): user_credentials = json.loads(creds_path.read_text()) elif defer_provisioning: @@ -114,6 +126,7 @@ def from_env(cls) -> "InstallContext": arch_config_path=arch_config_path, omarchy_install=omarchy_install, defer_provisioning=defer_provisioning, + profile=profile, state_dir=state_dir, ) disk_config = user_configuration.get("disk_config", {}) diff --git a/configs/airootfs/usr/share/omarchy-iso/orchestrator/main.py b/configs/airootfs/usr/share/omarchy-iso/orchestrator/main.py index 9b6ebf5b..2635c3b2 100644 --- a/configs/airootfs/usr/share/omarchy-iso/orchestrator/main.py +++ b/configs/airootfs/usr/share/omarchy-iso/orchestrator/main.py @@ -32,6 +32,7 @@ def build_phases(ctx: InstallContext): prepare_live, prepare_install_target, arch_install_system, + add_parent_disk_key, configure_hibernation, run_system_finalizer, stage_provisioning_state, @@ -49,6 +50,9 @@ def build_phases(ctx: InstallContext): ("Preparing live environment", prepare_live), ("Preparing install target", prepare_install_target), ("Installing Arch + Omarchy", arch_install_system), + # Child installs only: the parent password becomes a second LUKS key, + # while the kid's passphrase from the format can still open the add. + ("Adding the parent disk key", add_parent_disk_key), ("Configuring hibernation", configure_hibernation), ("Configuring system", run_system_finalizer), # Before finalize_limine_boot: the deferred-provisioning cryptkey drop-in and keyfile 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 6d486245..e350beb8 100644 --- a/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py +++ b/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py @@ -723,10 +723,18 @@ def _unmask_mkinitcpio_pacman_hooks( info(f"warning: failed to restore pacman hook mask for {name}: {exc}") +# Where build-iso.sh vendors the runtime's package lists and setup form. +ISO_SHARE = Path("/usr/share/omarchy-iso") + + def _runtime_package_list(ctx: InstallContext) -> list[str]: """Selected Omarchy runtime package + every package in the ISO-bundled - base package list that isn't already installed early.""" - base_pkgs_file = Path("/usr/share/omarchy-iso/omarchy-base.packages") + base package list that isn't already installed early. A child install adds + the runtime's child list on top; build-iso.sh vendors it beside the base + list and carries its packages in the offline mirror.""" + pkgs_files = [ISO_SHARE / "omarchy-base.packages"] + if ctx.profile == "child": + pkgs_files.append(ISO_SHARE / "omarchy-child.packages") pkgs = [_omarchy_runtime_package()] already_installed = set(_early_packages()) | { _omarchy_runtime_package(), @@ -736,15 +744,85 @@ def _runtime_package_list(ctx: InstallContext) -> list[str]: "omarchy-settings", "omarchy-nvim", } - for raw in base_pkgs_file.read_text().splitlines(): - s = raw.strip() - if not s or s.startswith("#"): - continue - if s not in already_installed and s not in pkgs: - pkgs.append(s) + for pkgs_file in pkgs_files: + for raw in pkgs_file.read_text().splitlines(): + s = raw.strip() + if not s or s.startswith("#"): + continue + if s not in already_installed and s not in pkgs: + pkgs.append(s) return pkgs +# ───────────────────────────────────────────────────────────────────────────── +# add_parent_disk_key: on an encrypted child install, key the LUKS volume to the +# parent password as well as the kid's. That second slot is what lets a parent +# boot a machine whose kid has forgotten their password and reset it from +# inside. The kid's passphrase (the one the disk was formatted with) unlocks +# the volume for the add; both travel as root-only key files, never argv. +# ───────────────────────────────────────────────────────────────────────────── + +def _luks_partition(ctx: InstallContext) -> str: + storage = _storage_intent(ctx) + if storage.get("luks_uuid"): + return f"/dev/disk/by-uuid/{storage['luks_uuid']}" + + # Full-disk installs let archinstall pick the partition; find the one it + # formatted on the install disk. + modifications = (ctx.user_configuration.get("disk_config") or {}).get("device_modifications") or [] + disks = [m.get("device") for m in modifications if m.get("device")] + res = capture(["blkid", "-t", "TYPE=crypto_LUKS", "-o", "device"]) + candidates = [line.strip() for line in res.stdout.splitlines() if line.strip()] + if disks: + candidates = [c for c in candidates if any(c.startswith(d) for d in disks)] + if len(candidates) != 1: + raise RuntimeError(f"expected one LUKS partition on {disks or 'the install disk'}, found {candidates}") + return candidates[0] + + +def _write_key_file(ctx: InstallContext, name: str, passphrase: str) -> Path: + path = ctx.state_dir / name + path.touch(mode=0o600, exist_ok=True) + path.chmod(0o600) + # Byte-for-byte the slot passphrase: no trailing newline. + path.write_text(passphrase) + return path + + +def add_parent_disk_key(ctx: InstallContext) -> None: + if ctx.profile != "child" or ctx.defer_provisioning or not _provision_install_encrypted(ctx): + return + + kid = _provision_encryption_password(ctx) + parent = ctx.user_credentials.get("parent_encryption_password") + if not kid or not parent: + raise RuntimeError( + "child install on an encrypted disk needs both encryption_password and " + "parent_encryption_password in user_credentials.json to key the disk to the parent" + ) + + device = _luks_partition(ctx) + kid_file = _write_key_file(ctx, "luks-kid-key", kid) + parent_file = _write_key_file(ctx, "luks-parent-key", parent) + try: + already = subprocess.run( + ["cryptsetup", "open", "--test-passphrase", "--key-file", str(parent_file), device], + capture_output=True, + ) + if already.returncode == 0: + info("› the parent password already unlocks the disk") + return + + info("› adding the parent password as a second disk key") + subprocess.run( + ["cryptsetup", "luksAddKey", "--key-file", str(kid_file), device, str(parent_file)], + check=True, + ) + finally: + kid_file.unlink(missing_ok=True) + parent_file.unlink(missing_ok=True) + + # ───────────────────────────────────────────────────────────────────────────── # Install intent helpers: normalize the Omarchy-specific part of the # configurator JSON so full-disk and pre-mounted installs feed the same boot @@ -1088,6 +1166,7 @@ def _run_target_setup_command(ctx: InstallContext, cmd: list[str], *, user: str "OMARCHY_PATH=/usr/share/omarchy", "OMARCHY_INSTALL=/usr/share/omarchy/install", f"OMARCHY_INSTALL_USER={ctx.username}", + f"OMARCHY_INSTALL_PROFILE={ctx.profile}", f"OMARCHY_START_TIME={omarchy_start_time}", f"OMARCHY_START_EPOCH={omarchy_start_epoch}", f"OMARCHY_USER_NAME={ctx.full_name}", @@ -1134,6 +1213,7 @@ def run_system_finalizer(ctx: InstallContext) -> None: cmd = ["/usr/bin/omarchy-apply-system", "--defer-provisioning", "--first-install"] else: cmd = ["/usr/bin/omarchy-apply-system", "--install-user", ctx.username, "--first-install"] + cmd += ["--profile", ctx.profile] _mask_mkinitcpio_pacman_hooks(ctx, ctx.target, TARGET_DEFERRED_BOOT_HOOKS) try: diff --git a/test/unit/test_child_profile.py b/test/unit/test_child_profile.py new file mode 100644 index 00000000..8798f0fc --- /dev/null +++ b/test/unit/test_child_profile.py @@ -0,0 +1,186 @@ +"""Unit tests for the child install profile (Omarchy's kids mode) in the +orchestrator: the profile read from the configurator's JSON, the child package +list on top of the base set, the --profile handoff to omarchy-apply-system, and +the parent password added as a second LUKS key.""" + +import json +import os +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().parents[2] / "configs/airootfs/usr/share/omarchy-iso")) + +sys.modules.setdefault( + "orchestrator.archinstall_adapter", types.ModuleType("orchestrator.archinstall_adapter") +) + +from orchestrator import phases_impl # noqa: E402 +from orchestrator.context import InstallContext # noqa: E402 + + +class ChildProfileContextTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.dir = Path(self.tmp.name) + self.env = { + "OMARCHY_INSTALL_CONFIG": str(self.dir / "user_configuration.json"), + "OMARCHY_INSTALL_CREDS": str(self.dir / "user_credentials.json"), + "OMARCHY_INSTALL_STATE_DIR": str(self.dir / "state"), + } + + def from_env(self, config, creds, **extra_env): + (self.dir / "user_configuration.json").write_text(json.dumps(config)) + (self.dir / "user_credentials.json").write_text(json.dumps(creds)) + with mock.patch.dict(os.environ, {**self.env, **extra_env}, clear=False): + return InstallContext.from_env() + + def config(self, profile=None, **omarchy_install): + block = {"mode": "full_disk", "target_mount": "/mnt", **omarchy_install} + if profile is not None: + block["profile"] = profile + return {"omarchy_install": block, "disk_config": {"config_type": "default_layout"}} + + def creds(self, **extra): + return { + "root_enc_password": "$6$parent", + "users": [{"username": "kid", "enc_password": "$6$kid", "groups": [], "sudo": False}], + **extra, + } + + def test_profile_defaults_when_absent(self): + ctx = self.from_env(self.config(), self.creds()) + self.assertEqual(ctx.profile, "default") + self.assertEqual(ctx.omarchy_install["profile"], "default") + + def test_child_profile_is_read(self): + ctx = self.from_env(self.config(profile="child"), self.creds()) + self.assertEqual(ctx.profile, "child") + + def test_unknown_profile_is_refused(self): + with self.assertRaises(RuntimeError): + self.from_env(self.config(profile="teen"), self.creds()) + + def test_deferred_child_install_drops_the_parent_passphrase(self): + ctx = self.from_env( + self.config(profile="child", defer_provisioning=True), + self.creds(encryption_password="kid-pass", parent_encryption_password="parent-pass"), + ) + self.assertEqual(ctx.profile, "child") + self.assertEqual(ctx.user_credentials.get("users"), []) + self.assertNotIn("parent_encryption_password", ctx.user_credentials) + + +class ChildPackageListTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + share = Path(self.tmp.name) + (share / "omarchy-base.packages").write_text("# base\nfoo\nbar\n") + (share / "omarchy-child.packages").write_text("# child\nfoo\ngcompris\n") + self.share = share + + def list_for(self, profile): + ctx = types.SimpleNamespace(profile=profile) + with mock.patch.object(phases_impl, "ISO_SHARE", self.share), \ + mock.patch.object(phases_impl, "_early_packages", return_value=[]), \ + mock.patch.object(phases_impl, "_omarchy_runtime_package", return_value="omarchy"), \ + mock.patch.object(phases_impl, "_omarchy_settings_package", return_value="omarchy-settings"), \ + mock.patch.object(phases_impl, "_omarchy_nvim_package", return_value="omarchy-nvim"): + return phases_impl._runtime_package_list(ctx) + + def test_default_profile_installs_the_base_list_only(self): + self.assertEqual(self.list_for("default"), ["omarchy", "foo", "bar"]) + + def test_child_profile_adds_the_child_list_without_duplicates(self): + self.assertEqual(self.list_for("child"), ["omarchy", "foo", "bar", "gcompris"]) + + +class ProfileHandoffTest(unittest.TestCase): + def test_system_finalizer_passes_the_profile(self): + ctx = types.SimpleNamespace(profile="child", defer_provisioning=False, username="kid", target=Path("/mnt")) + with mock.patch.object(phases_impl, "_run_target_setup_command") as run, \ + mock.patch.object(phases_impl, "_mask_mkinitcpio_pacman_hooks"), \ + mock.patch.object(phases_impl, "_unmask_mkinitcpio_pacman_hooks"): + phases_impl.run_system_finalizer(ctx) + cmd = run.call_args.args[1] + self.assertEqual(cmd[-2:], ["--profile", "child"]) + self.assertIn("--install-user", cmd) + + def test_deferred_finalizer_still_passes_the_profile(self): + ctx = types.SimpleNamespace(profile="child", defer_provisioning=True, username="", target=Path("/mnt")) + with mock.patch.object(phases_impl, "_run_target_setup_command") as run, \ + mock.patch.object(phases_impl, "_mask_mkinitcpio_pacman_hooks"), \ + mock.patch.object(phases_impl, "_unmask_mkinitcpio_pacman_hooks"): + phases_impl.run_system_finalizer(ctx) + cmd = run.call_args.args[1] + self.assertIn("--defer-provisioning", cmd) + self.assertEqual(cmd[-2:], ["--profile", "child"]) + + +class ParentDiskKeyTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.state_dir = Path(self.tmp.name) + + def ctx(self, profile="child", encrypted=True, parent="parent-pass", kid="kid-pass", deferred=False): + storage = {"luks_uuid": "1111-2222"} if encrypted else {} + creds = {"users": [{"username": "kid"}]} + if kid: + creds["encryption_password"] = kid + if parent: + creds["parent_encryption_password"] = parent + return types.SimpleNamespace( + profile=profile, + defer_provisioning=deferred, + encrypt=encrypted, + user_credentials=creds, + user_configuration={"disk_config": {"config_type": "pre_mounted_config"}}, + omarchy_install={"mode": "protected", "storage": storage}, + state_dir=self.state_dir, + ) + + def run_phase(self, ctx, test_passphrase_rc=1): + calls = [] + + def fake_run(cmd, **kwargs): + calls.append(cmd) + rc = test_passphrase_rc if "--test-passphrase" in cmd else 0 + return CompletedProcess(cmd, rc) + + with mock.patch.object(phases_impl.subprocess, "run", side_effect=fake_run): + phases_impl.add_parent_disk_key(ctx) + return calls + + def test_adds_the_parent_key_with_the_kid_passphrase_unlocking(self): + calls = self.run_phase(self.ctx()) + add = [c for c in calls if c[:2] == ["cryptsetup", "luksAddKey"]] + self.assertEqual(len(add), 1) + self.assertEqual(add[0][2:4], ["--key-file", str(self.state_dir / "luks-kid-key")]) + self.assertEqual(add[0][4], "/dev/disk/by-uuid/1111-2222") + self.assertEqual(add[0][5], str(self.state_dir / "luks-parent-key")) + self.assertFalse((self.state_dir / "luks-kid-key").exists(), "key files are removed afterwards") + self.assertFalse((self.state_dir / "luks-parent-key").exists(), "key files are removed afterwards") + + def test_is_idempotent_when_the_parent_key_already_unlocks(self): + calls = self.run_phase(self.ctx(), test_passphrase_rc=0) + self.assertFalse(any(c[:2] == ["cryptsetup", "luksAddKey"] for c in calls)) + + def test_does_nothing_outside_child_or_encrypted_installs(self): + self.assertEqual(self.run_phase(self.ctx(profile="default")), []) + self.assertEqual(self.run_phase(self.ctx(encrypted=False)), []) + self.assertEqual(self.run_phase(self.ctx(deferred=True)), []) + + def test_refuses_without_both_passphrases(self): + with self.assertRaises(RuntimeError): + self.run_phase(self.ctx(parent=None)) + + +if __name__ == "__main__": + unittest.main()