diff --git a/bin/omarchy-first-run b/bin/omarchy-provision-first-run similarity index 96% rename from bin/omarchy-first-run rename to bin/omarchy-provision-first-run index 09d3fca6..6070edce 100755 --- a/bin/omarchy-first-run +++ b/bin/omarchy-provision-first-run @@ -7,7 +7,7 @@ set -e usage() { cat <&2 + exit 1 +fi + +[[ -f $PROVISIONING_DIR/pending ]] || exit 0 + +# Never set up a user on a half-wiped system: while a factory wipe is still +# pending (i.e. it failed this boot), stand down and let it retry next boot. +if [[ -f $PROVISIONING_DIR/wipe-pending ]]; then + echo "omarchy-provision-owner: factory wipe still pending; not running user setup" >&2 + exit 1 +fi + +export PATH="$OMARCHY_PATH/bin:$PATH" + +LOGO_PATH="$OMARCHY_PATH/logo.txt" +LOGO_WIDTH=$(awk '{ if (length > max) max = length } END { print max+0 }' "$LOGO_PATH" 2>/dev/null || echo 0) + +export GUM_CONFIRM_PROMPT_FOREGROUND="6" +export GUM_CONFIRM_SELECTED_FOREGROUND="0" +export GUM_CONFIRM_SELECTED_BACKGROUND="2" +export GUM_CONFIRM_UNSELECTED_FOREGROUND="7" +export GUM_CONFIRM_UNSELECTED_BACKGROUND="0" + +# Same Tokyo Night VT palette the ISO configurator sets, so the first-boot +# form looks like a continuation of the install. +set_tokyo_night_colors() { + [[ $(tty 2>/dev/null) == /dev/tty* ]] || return 0 + echo -en "\e]P01a1b26"; echo -en "\e]P1f7768e"; echo -en "\e]P29ece6a" + echo -en "\e]P3e0af68"; echo -en "\e]P47aa2f7"; echo -en "\e]P5bb9af7" + echo -en "\e]P67dcfff"; echo -en "\e]P7a9b1d6"; echo -en "\e]P8414868" + echo -en "\e]P9f7768e"; echo -en "\e]PA9ece6a"; echo -en "\e]PBe0af68" + echo -en "\e]PC7aa2f7"; echo -en "\e]PDbb9af7"; echo -en "\e]PE7dcfff" + echo -en "\e]PFc0caf5" + echo -en "\033[0m" + clear +} + +measure_terminal() { + TERM_WIDTH=$(stty size 2>/dev/null 0 )) || TERM_WIDTH=${COLUMNS:-80} + + PADDING_LEFT=$(((TERM_WIDTH - LOGO_WIDTH) / 2)) + (( PADDING_LEFT < 0 )) && PADDING_LEFT=0 + PADDING_LEFT_SPACES=$(printf "%*s" "$PADDING_LEFT" "") + + local padding="0 0 0 $PADDING_LEFT" + export GUM_CHOOSE_PADDING="$padding" + export GUM_INPUT_PADDING="$padding" + export GUM_SPIN_PADDING="$padding" + export GUM_TABLE_PADDING="$padding" + export GUM_CONFIRM_PADDING="$padding" +} + +clear_logo() { + measure_terminal + printf "\033[H\033[2J" + gum style --foreground 2 --padding "1 0 0 $PADDING_LEFT" "$(<"$LOGO_PATH")" +} + +step() { + clear_logo + echo + gum style --padding "0 0 0 $PADDING_LEFT" "$1" + echo +} + +say() { + gum style --padding "0 0 0 $PADDING_LEFT" "$@" +} + +notice() { + clear_logo + echo + gum spin --spinner "pulse" --title "$1" -- sleep "${2:-2}" + echo +} + +log_step() { + echo "[$(date '+%Y-%m-%d %H:%M:%S')] oem-setup: $1" >>"$LOG_FILE" +} + +# The keyboard step the ISO configurator runs — deferred to first boot for OEM +# installs, so the machine's owner picks their own layout. Applied immediately +# (live VT + persisted) so the password typed next, and the LUKS re-key below, +# use the chosen layout. +keyboard_form() { + step "Let's setup your keyboard..." + local keyboards choice + keyboards=$'Azerbaijani|azerty +Belarusian|by +Belgian|be-latin1 +Bosnian|ba +Bulgarian|bg-cp1251 +Croatian|croat +Czech|cz +Danish|dk-latin1 +Dutch|nl +English (UK)|uk +English (US)|us +English (US, Dvorak)|dvorak +English (US, Colemak)|colemak +Estonian|et +Finnish|fi +French|fr +French (Canada)|cf +French (Switzerland)|fr_CH +Georgian|ge +German|de +German (Switzerland)|de_CH-latin1 +Greek|gr +Hebrew|il +Hungarian|hu +Icelandic|is-latin1 +Irish|ie +Italian|it +Japanese|jp106 +Kazakh|kazakh +Khmer (Cambodia)|khmer +Kyrgyz|kyrgyz +Lao|la-latin1 +Latvian|lv +Lithuanian|lt +Macedonian|mk-utf +Norwegian|no-latin1 +Polish|pl +Portuguese|pt-latin1 +Portuguese (Brazil)|br-abnt2 +Romanian|ro +Russian|ru +Serbian|sr-latin +Slovak|sk-qwertz +Slovenian|slovene +Spanish|es +Spanish (Latin American)|la-latin1 +Swedish|sv-latin1 +Tajik|tj_alt-UTF8 +Turkish|trq +Ukrainian|ua' + choice=$(printf '%s\n' "$keyboards" | cut -d'|' -f1 | gum choose --height 10 --selected "English (US)" --header "Select keyboard layout") || choice="English (US)" + keyboard=$(printf '%s\n' "$keyboards" | awk -F'|' -v c="$choice" '$1==c{print $2; exit}') + keyboard_label="$choice" + apply_keyboard "$keyboard" +} + +# Load the layout on the live VT and persist it for the installed system. +# systemd-firstboot writes both the console KEYMAP and the XKB layout Hyprland +# reads, matching what the ISO's configure_keyboard does at install time. The +# two layouts localectl doesn't know (ba, khmer) keep the default, same as a +# normal install. +apply_keyboard() { + local keymap="$1" + [[ $(tty 2>/dev/null) == /dev/tty* ]] && loadkeys "$keymap" 2>/dev/null || true + + if localectl --no-pager list-keymaps 2>/dev/null | grep -qix "$keymap"; then + systemd-firstboot --keymap="$keymap" --force >>"$LOG_FILE" 2>&1 || \ + localectl set-keymap "$keymap" >>"$LOG_FILE" 2>&1 || \ + log_step "could not persist keymap $keymap" + else + log_step "keymap $keymap unknown to localectl; keeping the default" + fi +} + +# The same username/password/name/email form as the ISO configurator's Step 2. +user_form() { + step "Let's setup your user account..." + + # A prior attempt that already created the account pins the username, so a + # retry cannot strand that account by choosing a different name. + if [[ -f $PROVISIONING_DIR/setup-user ]]; then + username=$(<"$PROVISIONING_DIR/setup-user") + say "Continuing setup for user: $username" + echo + else + while true; do + username=$(gum input --placeholder "Alphanumeric without spaces (like dhh)" --prompt.foreground="#845DF9" --prompt "Username> ") || continue + + if [[ "$username" =~ ^[a-z_][a-z0-9_-]*[$]?$ ]]; then + if [[ "$username" =~ ^(root|bin|daemon|mail|ftp|http|nobody|dbus|systemd-coredump|systemd-network|systemd-oom|systemd-journal-remote|systemd-resolve|systemd-timesync|tss|uuidd|alpm|git|avahi|cups|lp|_talkd|polkitd|rtkit|qemu|brltty|gluster|rpc|libvirt-qemu|pcscd|nvidia-persistenced|sddm)$ ]]; then + notice "Username is reserved for system" 1 + elif getent passwd "$username" >/dev/null; then + # provisioning state has no users, so any existing account is off limits; + # a pinned resume (above) bypasses this prompt entirely. + notice "That username already exists on this machine" 1 + else + break + fi + else + notice "Username must be alphanumeric with no spaces" 1 + fi + done + fi + + while true; do + password=$(gum input --placeholder "Used for user + root, and disk encryption when enabled" --prompt.foreground="#845DF9" --password --prompt "Password> ") || continue + password_confirmation=$(gum input --placeholder "Must match the password you just typed" --prompt.foreground="#845DF9" --password --prompt "Confirm> ") || continue + + if [[ -n "$password" && "$password" == "$password_confirmation" ]]; then + break + elif [[ -z "$password" ]]; then + notice "Your password can't be blank!" 1 + else + notice "Passwords didn't match!" 1 + fi + done + + full_name=$(gum input --placeholder "Used for git authentication (hit return to skip)" --prompt.foreground="#845DF9" --prompt "Full name> ") || full_name="" + email_address=$(gum input --placeholder "Used for git authentication (hit return to skip)" --prompt.foreground="#845DF9" --prompt "Email address> ") || email_address="" +} + +confirm_form() { + clear_logo + echo + echo -e "Field,Value +Keyboard,${keyboard_label:-English (US)} +Username,$username +Password,$(printf "%${#password}s" | tr ' ' '*') +Full name,${full_name:-[Skipped]} +Email address,${email_address:-[Skipped]}" | + gum table -s "," -p | sed "s/^/${PADDING_LEFT_SPACES}/" + + echo + gum confirm --negative "No, change it" "Does this look right?" +} + +# Groups recorded by omarchy-setup-system's scripts at install time +# (/var/lib/omarchy/provisioning/groups), filtered to groups that exist on this system. +user_groups() { + local groups="wheel" group + if [[ -f $PROVISIONING_DIR/groups ]]; then + while IFS= read -r group; do + [[ -n $group ]] || continue + getent group "$group" >/dev/null || continue + [[ ",$groups," == *",$group,"* ]] || groups+=",$group" + done <"$PROVISIONING_DIR/groups" + fi + echo "$groups" +} + +# Resolve the crypto_LUKS partition backing the root, or return non-zero if +# the root is not on LUKS. Prefers the cmdline cryptdevice= spec (archinstall +# writes PARTUUID=, the pre-mounted path UUID=), and falls back to walking the +# device tree for roots reached via rd.luks/crypttab with a plain /dev/mapper +# root and no cryptdevice=. +luks_device() { + local spec + spec=$(grep -o 'cryptdevice=[^ :]*' /proc/cmdline | head -1 | cut -d= -f2-) + case $spec in + UUID=*) echo "/dev/disk/by-uuid/${spec#UUID=}"; return 0 ;; + PARTUUID=*) echo "/dev/disk/by-partuuid/${spec#PARTUUID=}"; return 0 ;; + LABEL=*) echo "/dev/disk/by-label/${spec#LABEL=}"; return 0 ;; + PARTLABEL=*) echo "/dev/disk/by-partlabel/${spec#PARTLABEL=}"; return 0 ;; + /dev/*) echo "$spec"; return 0 ;; + esac + + # No cryptdevice=: walk the root source's ancestors (lsblk -s inverts the + # tree) for the first crypto_LUKS parent. + local src part + src=$(findmnt -no SOURCE / | sed 's/\[.*//') + [[ -n $src ]] || return 1 + part=$(lsblk -nspo NAME,FSTYPE "$src" 2>/dev/null | awk '$2=="crypto_LUKS"{print $1; exit}') + [[ -n $part ]] && { echo "$part"; return 0; } + return 1 +} + +encrypted_install() { + luks_device >/dev/null 2>&1 +} + +create_user() { + # Pin the username so a retry after a later failure resumes this exact + # account rather than creating a second privileged one. + echo "$username" >"$PROVISIONING_DIR/setup-user" + + if getent passwd "$username" >/dev/null; then + # Resuming a partially-completed earlier attempt: refresh what the form + # collected this time around. + usermod -aG "$(user_groups)" ${full_name:+-c "$full_name"} "$username" + else + useradd -m -G "$(user_groups)" -s /bin/bash \ + ${full_name:+-c "$full_name"} "$username" + fi + + printf '%s:%s\n' "$username" "$password" | chpasswd + printf '%s:%s\n' root "$password" | chpasswd + + # deferred-provisioning installs skip archinstall's create_users, which is what normally + # uncomments %wheel in /etc/sudoers. Always write the drop-in: detecting an + # existing grant is error-prone (omarchy ships narrow %wheel NOPASSWD rules + # for specific commands), and a duplicate grant is harmless. + echo "%wheel ALL=(ALL:ALL) ALL" >/etc/sudoers.d/00-omarchy-wheel + chmod 440 /etc/sudoers.d/00-omarchy-wheel +} + +install_authorized_keys() { + [[ -f $PROVISIONING_DIR/authorized_keys ]] || return 0 + + local ssh_dir="/home/$username/.ssh" + mkdir -p "$ssh_dir" + cp "$PROVISIONING_DIR/authorized_keys" "$ssh_dir/authorized_keys" + chmod 700 "$ssh_dir" + chmod 600 "$ssh_dir/authorized_keys" + chown -R "$username:$username" "$ssh_dir" +} + +configure_login() { + mkdir -p /var/lib/sddm + printf '[Last]\nSession=omarchy.desktop\nUser=%s\n' "$username" >/var/lib/sddm/state.conf + chown -R sddm:sddm /var/lib/sddm 2>/dev/null || true + + # Encrypted installs autologin because the LUKS prompt is the auth boundary. + if encrypted_install; then + mkdir -p /etc/sddm.conf.d + printf '[Autologin]\nUser=%s\nSession=omarchy.desktop\n' "$username" >/etc/sddm.conf.d/autologin.conf + fi +} + +finalize_user() { + local home shell + home=$(getent passwd "$username" | cut -d: -f6) + shell=$(getent passwd "$username" | cut -d: -f7) + + runuser -u "$username" -- env \ + HOME="$home" \ + USER="$username" \ + LOGNAME="$username" \ + SHELL="${shell:-/bin/bash}" \ + OMARCHY_PATH="$OMARCHY_PATH" \ + OMARCHY_INSTALL="$OMARCHY_PATH/install" \ + OMARCHY_SETUP_CONTEXT=provision-owner \ + OMARCHY_USER_NAME="$full_name" \ + OMARCHY_USER_EMAIL="$email_address" \ + OMARCHY_LOG_TO_STDOUT=1 \ + "$OMARCHY_PATH/bin/omarchy-provision-user" --force --first-install +} + +# Move the LUKS volume from the throwaway install passphrase to the user's +# password: add the user's key, kill every other slot (throwaway + any seller +# keys a reset left behind), then rebuild the UKI without the embedded +# auto-unlock keyfile. +# +# Failing here must be LOUD (abort the attempt, offer retry): silently keeping +# the staged auto-unlock keyfile would leave the disk effectively unencrypted +# forever. +rekey_luks() { + [[ -f $PROVISIONING_DIR/luks-key ]] || return 0 + + local device + if ! device=$(luks_device) || [[ ! -e $device ]]; then + log_step "cannot locate the LUKS device from /proc/cmdline: $(cat /proc/cmdline)" + say --foreground 1 "Could not locate the LUKS device to re-key." + return 1 + fi + + if ! cryptsetup open --test-passphrase --key-file "$PROVISIONING_DIR/luks-key" "$device" 2>>"$LOG_FILE"; then + log_step "staged LUKS key does not unlock $device" + say --foreground 1 "The staged LUKS key no longer unlocks $device." + return 1 + fi + + # Add the user's key (a retry with a different password just adds another + # slot; all but the current one are killed once the rebuild succeeds). + cryptsetup luksAddKey --key-file "$PROVISIONING_DIR/luks-key" "$device" <(printf '%s' "$password") + + # Rebuild the no-auto-unlock UKI FIRST, keeping the throwaway key and slot as + # a fallback. Only once that succeeds do we kill the other slots and destroy + # the staged key — so a limine-update failure leaves a recoverable, + # still-auto-unlocking state to retry, never a disk locked to a password the + # user may have just changed. + rm -f /etc/omarchy/provisioning.key \ + /etc/limine-entry-tool.d/99-omarchy-provisioning-unlock.conf \ + /etc/mkinitcpio.conf.d/99-omarchy-provisioning-key.conf + reset_limine_config + if ! limine-update >>"$LOG_FILE" 2>&1; then + log_step "limine-update failed during re-key; restoring auto-unlock for retry" + install -Dm600 "$PROVISIONING_DIR/luks-key" /etc/omarchy/provisioning.key + echo 'KERNEL_CMDLINE[default]+=" cryptkey=rootfs:/etc/omarchy/provisioning.key"' \ + >/etc/limine-entry-tool.d/99-omarchy-provisioning-unlock.conf + echo 'FILES+=(/etc/omarchy/provisioning.key)' >/etc/mkinitcpio.conf.d/99-omarchy-provisioning-key.conf + limine-update >>"$LOG_FILE" 2>&1 || true + return 1 + fi + + local new_slot slot + new_slot=$(cryptsetup open --test-passphrase --verbose --key-file <(printf '%s' "$password") "$device" 2>&1 | + grep -o 'Key slot [0-9]* unlocked' | grep -o '[0-9]*' | head -1) + if [[ -n $new_slot ]]; then + for slot in $(cryptsetup luksDump "$device" | awk '/^ +[0-9]+: luks2/ { sub(":", "", $1); print $1 }'); do + [[ $slot == "$new_slot" ]] && continue + cryptsetup luksKillSlot -q --key-file <(printf '%s' "$password") "$device" "$slot" + done + fi + + shred -u "$PROVISIONING_DIR/luks-key" 2>/dev/null || rm -f "$PROVISIONING_DIR/luks-key" +} + +# Start the ESP's limine.conf over from the shipped template and drop foreign +# machine-id state before rebuilding. limine-entry-tool keys OS entries by +# machine-id: after a factory reset gave this machine a fresh identity, the +# previous system's entry would survive the rebuild with a stale UKI hash, +# sort first, and make Limine stop at a hash-mismatch warning. +esp_path() { + local esp="" + if [[ -f /etc/default/limine ]]; then + esp=$(sed -n 's/^ESP_PATH=["'\'']\?\([^"'\'']*\).*/\1/p' /etc/default/limine | tail -1) + fi + echo "${esp:-/boot}" +} + +# A factory reset gives the machine a fresh machine-id, but limine-entry-tool +# keys its limine.conf entries by machine-id — entries from the previous +# identity would linger and go hash-stale on the first UKI rebuild. +limine_entries_stale() { + local esp machine_id + esp=$(esp_path) + machine_id=$(cat /etc/machine-id 2>/dev/null || true) + [[ -f $esp/limine.conf && -n $machine_id ]] || return 1 + # Stale if any foreign machine-id entry lingers, OR if this machine has no + # entry at all (e.g. a failed earlier rebuild left the entry-less template). + grep -o 'machine-id=[0-9a-f]*' "$esp/limine.conf" 2>/dev/null | + grep -qv "machine-id=$machine_id" && return 0 + ! grep -q "machine-id=$machine_id" "$esp/limine.conf" +} + +reset_limine_config() { + local esp template found="" machine_id old_id old_ids="" + esp=$(esp_path) + + # Only remove machine-ids the old (Omarchy-managed) limine.conf referenced; + # a shared ESP may hold other installations' machine-id directories. + if [[ -f $esp/limine.conf ]]; then + old_ids=$(grep -o 'machine-id=[0-9a-f]\{32\}' "$esp/limine.conf" | cut -d= -f2 | sort -u) + fi + + for template in "$OMARCHY_PATH/install/assets/limine/limine.conf" \ + "$OMARCHY_PATH/default/limine/limine.conf"; do + if [[ -f $template ]]; then + cp "$template" "$esp/limine.conf" + found=1 + break + fi + done + if [[ -z $found ]]; then + log_step "no limine.conf template found; keeping the existing config" + return 0 + fi + + machine_id=$(cat /etc/machine-id 2>/dev/null || true) + for old_id in $old_ids; do + [[ $old_id == "$machine_id" ]] && continue + rm -rf "${esp:?}/$old_id" + done +} + +cleanup_oem_state() { + # Keep groups + packages: omarchy-system-factory-reset's degraded path (machines + # without @factory) re-arms first-boot setup from the live copies. + rm -f "$PROVISIONING_DIR/pending" "$PROVISIONING_DIR/authorized_keys" "$PROVISIONING_DIR/setup-user" + + rm -f /etc/systemd/system/multi-user.target.wants/omarchy-provision-owner.service + systemctl daemon-reload 2>/dev/null || true +} + +run_setup() { + while true; do + keyboard_form + user_form + confirm_form && break + done + + clear_logo + echo + say "Setting up your account..." + echo + + touch "$LOG_FILE" + chmod 600 "$LOG_FILE" + + log_step "creating user $username" + create_user + install_authorized_keys + configure_login + + log_step "finalizing user" + say "Finalizing your user (this can take a minute)..." + if ! finalize_user >>"$LOG_FILE" 2>&1; then + log_step "finalize-user failed (continuing; user can retry after login)" + say --foreground 1 "User finalization reported errors (see $LOG_FILE)." + say --foreground 1 "Run 'omarchy-provision-user --force' after login to retry." + sleep 3 + fi + + if [[ -f $PROVISIONING_DIR/luks-key ]]; then + log_step "re-keying LUKS to the user's password" + say "Securing disk encryption with your password..." + rekey_luks + log_step "LUKS re-key complete" + fi + + # After a factory reset on an unencrypted machine nothing above rebuilds the + # boot entries, so entries keyed to the previous machine identity would + # linger and go hash-stale on the first UKI rebuild. Refresh them now. + if limine_entries_stale; then + log_step "refreshing boot entries for the new machine identity" + say "Refreshing boot entries (this can take a minute)..." + reset_limine_config + limine-update >>"$LOG_FILE" 2>&1 + fi + + log_step "cleaning up provisioning state" + cleanup_oem_state + log_step "first-boot setup complete" + + clear_logo + echo + gum spin --spinner "pulse" --title "All set. Starting Omarchy..." -- sleep 2 +} + +# A failed first-boot setup must not strand the machine at a user-less login +# screen. Each attempt runs as its own process — bash ignores errexit inside +# `while !` conditions, but a child process keeps its own set -e — and failure +# offers a retry; create_user and friends are idempotent, so retrying is safe. +if [[ ${1:-} == "--attempt" ]]; then + run_setup + exit 0 +fi + +main() { + set_tokyo_night_colors + + while ! "$0" --attempt; do + clear_logo + echo + say --foreground 1 "Setup hit an error (details in $LOG_FILE)." + echo + if ! gum confirm --affirmative "Try again" --negative "Drop to console" "Retry first-boot setup?"; then + say "Run 'omarchy-provision-owner' as root to retry later." + exit 1 + fi + done +} + +main diff --git a/bin/omarchy-finalize-user b/bin/omarchy-provision-user similarity index 91% rename from bin/omarchy-finalize-user rename to bin/omarchy-provision-user index 81db68d4..1e67c8df 100755 --- a/bin/omarchy-finalize-user +++ b/bin/omarchy-provision-user @@ -25,7 +25,7 @@ USAGE } if (( EUID == 0 )); then - echo "Error: run omarchy-finalize-user as the user being configured, not as root." >&2 + echo "Error: run omarchy-provision-user as the user being configured, not as root." >&2 exit 1 fi @@ -67,7 +67,9 @@ export OMARCHY_INSTALL="${OMARCHY_INSTALL:-$OMARCHY_PATH/install}" export OMARCHY_SETUP_CONTEXT="${OMARCHY_SETUP_CONTEXT:-runtime}" export PATH="$OMARCHY_PATH/bin:$PATH" -if (( first_install )); then +if (( first_install )) && [[ $OMARCHY_SETUP_CONTEXT == "runtime" ]]; then + # The ISO chroot doesn't set a context; omarchy-provision-owner runs first-install + # finalization too but with OMARCHY_SETUP_CONTEXT=provision-owner already set. export OMARCHY_SETUP_CONTEXT=iso-chroot fi diff --git a/bin/omarchy-setup-hardware b/bin/omarchy-setup-hardware index d1255754..ebfcdbb4 100755 --- a/bin/omarchy-setup-hardware +++ b/bin/omarchy-setup-hardware @@ -10,20 +10,29 @@ set -euo pipefail usage() { cat <&2 - exit 1 -fi +if (( defer_provisioning )); then + install_user="" +else + if [[ -z $install_user || $install_user == "root" ]]; then + echo "Error: --install-user must name the target non-root user" >&2 + exit 1 + fi -if ! getent passwd "$install_user" >/dev/null; then - echo "Error: user '$install_user' does not exist" >&2 - exit 1 + if ! getent passwd "$install_user" >/dev/null; then + echo "Error: user '$install_user' does not exist" >&2 + exit 1 + fi fi export OMARCHY_INSTALL_USER="$install_user" diff --git a/bin/omarchy-setup-system b/bin/omarchy-setup-system index a0d27d22..b55296c2 100755 --- a/bin/omarchy-setup-system +++ b/bin/omarchy-setup-system @@ -10,16 +10,22 @@ set -euo pipefail usage() { cat <&2 - exit 1 -fi +if (( defer_provisioning )); then + install_user="" +else + if [[ -z $install_user || $install_user == "root" ]]; then + echo "Error: --install-user must name the target non-root user" >&2 + exit 1 + fi -if ! getent passwd "$install_user" >/dev/null; then - echo "Error: user '$install_user' does not exist" >&2 - exit 1 + if ! getent passwd "$install_user" >/dev/null; then + echo "Error: user '$install_user' does not exist" >&2 + exit 1 + fi fi export OMARCHY_INSTALL_USER="$install_user" @@ -75,7 +89,11 @@ start_install_log source "$OMARCHY_INSTALL/config/all.sh" -omarchy-setup-hardware --install-user "$install_user" +if (( defer_provisioning )); then + omarchy-setup-hardware --defer-provisioning +else + omarchy-setup-hardware --install-user "$install_user" +fi source "$OMARCHY_INSTALL/login/all.sh" source "$OMARCHY_INSTALL/post-install/all.sh" diff --git a/bin/omarchy-system-factory-reset b/bin/omarchy-system-factory-reset new file mode 100755 index 00000000..7648663d --- /dev/null +++ b/bin/omarchy-system-factory-reset @@ -0,0 +1,480 @@ +#!/bin/bash + +# omarchy:summary=Factory-reset this machine back to its freshly-installed state +# omarchy:group=setup +# omarchy:requires-sudo=true +# omarchy:examples=sudo omarchy-system-factory-reset + +# Returns the machine to provisioning state — fully installed, no user, first-boot +# setup pending — the state you want before selling or handing the machine on. +# +# On machines installed with an ISO that created the @factory snapshot, the +# running root (@) is swapped for a fresh writable clone of @factory, so +# user-installed packages and /etc drift are gone too. Machines without +# @factory get a degraded reset: the current system is kept and only user +# accounts, home data, and machine identity are wiped. +# +# The heavy lifting happens on the next boot via omarchy-system-factory-reset-finish, staged +# here. On encrypted installs the LUKS volume is re-keyed to a throwaway +# passphrase with an auto-unlock keyfile for the provisioning window; first-boot setup +# re-keys it to the new owner's password and removes the keyfile. +# +# Honest limitations: +# - On unencrypted disks this is deletion, not forensic erasure. fstrim helps +# on SSDs; use blkdiscard + a reinstall if you need certainty. +# - LUKS re-keying changes passphrases, not the volume key. Freed extents +# remain readable to someone with raw-device access and a valid passphrase. +# Use cryptsetup reencrypt or a reinstall if you need certainty. + +set -euo pipefail + +PROVISIONING_DIR=/var/lib/omarchy/provisioning +OMARCHY_PATH="${OMARCHY_PATH:-/usr/share/omarchy}" +TOP_MNT=/run/omarchy-system-factory-reset/top +NEXT_NAME=@omarchy-reset-next +LOG_FILE=/var/log/omarchy-system-factory-reset.log + +# Self-elevate so the menu entry (and a bare shell invocation) need no sudo +# prefix. The caller's gum theme vars are forwarded as `env` arguments rather +# than via `sudo -E`, so styling survives even under an env_reset sudoers. +if (( EUID != 0 )); then + mapfile -t gum_env < <(env | grep '^GUM_' || true) + exec sudo env "${gum_env[@]}" "$0" "$@" +fi + +export PATH="$OMARCHY_PATH/bin:$PATH" + +log() { + echo "$1" | tee -a "$LOG_FILE" >/dev/null + gum style --foreground 8 " $1" +} + +fail() { + gum style --foreground 1 "Error: $1" + exit 1 +} + +root_device() { + findmnt -no SOURCE / | sed 's/\[.*\]//' +} + +root_is_btrfs_at() { + [[ $(findmnt -no FSTYPE /) == btrfs ]] && findmnt -no OPTIONS / | grep -q "subvol=/@\(,\|$\)" +} + +# Resolve the crypto_LUKS partition backing the root, or return non-zero if +# the root is not on LUKS. Prefers the cmdline cryptdevice= spec (archinstall +# writes PARTUUID=, the pre-mounted path UUID=), and falls back to walking the +# device tree for roots reached via rd.luks/crypttab with a plain /dev/mapper +# root and no cryptdevice=. +luks_device() { + local spec + spec=$(grep -o 'cryptdevice=[^ :]*' /proc/cmdline | head -1 | cut -d= -f2-) + case $spec in + UUID=*) echo "/dev/disk/by-uuid/${spec#UUID=}"; return 0 ;; + PARTUUID=*) echo "/dev/disk/by-partuuid/${spec#PARTUUID=}"; return 0 ;; + LABEL=*) echo "/dev/disk/by-label/${spec#LABEL=}"; return 0 ;; + PARTLABEL=*) echo "/dev/disk/by-partlabel/${spec#PARTLABEL=}"; return 0 ;; + /dev/*) echo "$spec"; return 0 ;; + esac + + local src part + src=$(findmnt -no SOURCE / | sed 's/\[.*//') + [[ -n $src ]] || return 1 + part=$(lsblk -nspo NAME,FSTYPE "$src" 2>/dev/null | awk '$2=="crypto_LUKS"{print $1; exit}') + [[ -n $part ]] && { echo "$part"; return 0; } + return 1 +} + +encrypted_install() { + luks_device >/dev/null 2>&1 +} + +# No trailing `| head` stage: under pipefail an infinite tr killed by +# SIGPIPE fails the substitution and errexit aborts the whole reset. +generate_passphrase() { + local raw + raw=$(head -c 4096 /dev/urandom | tr -dc 'a-zA-Z0-9') + printf '%s' "${raw:0:48}" +} + +cleanup() { + if (( ${swap_done:-0} == 0 )); then + rollback_degraded_rekey + fi + if mountpoint -q "$TOP_MNT" 2>/dev/null; then + if [[ -d $TOP_MNT/$NEXT_NAME && ${swap_done:-0} == 0 ]]; then + btrfs subvolume delete --recursive "$TOP_MNT/$NEXT_NAME" >/dev/null 2>&1 || true + fi + umount -R "$TOP_MNT" 2>/dev/null || true + fi + rmdir "$TOP_MNT" 2>/dev/null || true +} +trap cleanup EXIT + +confirm_reset() { + local degraded="$1" + + echo + gum style --bold --foreground 1 "Reset this computer to factory state?" + echo + gum style "This will permanently erase:" + gum style " • All user accounts and everything in /home" + if [[ $degraded == true ]]; then + gum style --foreground 3 " • Machine identity (network connections, host keys, machine-id)" + echo + gum style --foreground 3 "This machine was installed before factory snapshots existed, so the" + gum style --foreground 3 "current system (installed packages, /etc changes) is kept — only user" + gum style --foreground 3 "accounts and machine state are wiped." + else + gum style " • All packages and system changes made since installation" + gum style " • Machine identity (network connections, host keys, machine-id)" + fi + echo + gum style "The next boot asks for a new user, exactly like a fresh install." + gum style --foreground 8 "Note: on disks without encryption this is deletion, not secure erasure." + echo + + local typed + typed=$(gum input --placeholder "Type 'reset' to continue" --prompt "> ") || exit 1 + [[ $typed == "reset" ]] || fail "Reset not confirmed." +} + +# Re-key to a throwaway passphrase whose keyfile auto-unlocks boot during the +# provisioning window. $1 is the root of the system the staging applies to ("" for the +# running root in degraded mode). +stage_luks_rekey() { + local next="$1" device passphrase + + device=$(luks_device) || fail "encrypted install, but no usable cryptdevice= in /proc/cmdline" + [[ -e $device ]] || fail "LUKS device $device not found" + + echo + gum style "Confirm your disk encryption passphrase to authorize the re-key." + local current + while true; do + current=$(gum input --password --placeholder "Current disk encryption passphrase" --prompt "Passphrase> ") || exit 1 + if cryptsetup open --test-passphrase --key-file <(printf '%s' "$current") "$device" 2>/dev/null; then + break + fi + gum style --foreground 1 "That passphrase does not unlock $device. Try again." + done + + passphrase=$(generate_passphrase) + cryptsetup luksAddKey --key-file <(printf '%s' "$current") "$device" <(printf '%s' "$passphrase") + + install -d -m 755 "$next$PROVISIONING_DIR" + printf '%s' "$passphrase" >"$next$PROVISIONING_DIR/luks-key" + chmod 600 "$next$PROVISIONING_DIR/luks-key" + + install -d -m 755 "$next/etc/omarchy" + printf '%s' "$passphrase" >"$next/etc/omarchy/provisioning.key" + chmod 600 "$next/etc/omarchy/provisioning.key" + + install -d "$next/etc/limine-entry-tool.d" "$next/etc/mkinitcpio.conf.d" + echo 'KERNEL_CMDLINE[default]+=" cryptkey=rootfs:/etc/omarchy/provisioning.key"' \ + >"$next/etc/limine-entry-tool.d/99-omarchy-provisioning-unlock.conf" + echo 'FILES+=(/etc/omarchy/provisioning.key)' \ + >"$next/etc/mkinitcpio.conf.d/99-omarchy-provisioning-key.conf" +} + +install_provisioning_units() { + local next="$1" unit_src="$2" + + install -Dm644 "$unit_src/omarchy-provision-owner.service" \ + "$next/etc/systemd/system/omarchy-provision-owner.service" + install -Dm644 "$unit_src/omarchy-system-factory-reset-finish.service" \ + "$next/etc/systemd/system/omarchy-system-factory-reset-finish.service" + + install -d "$next/etc/systemd/system/multi-user.target.wants" \ + "$next/etc/systemd/system/sysinit.target.wants" + ln -sf /etc/systemd/system/omarchy-provision-owner.service \ + "$next/etc/systemd/system/multi-user.target.wants/omarchy-provision-owner.service" + ln -sf /etc/systemd/system/omarchy-system-factory-reset-finish.service \ + "$next/etc/systemd/system/sysinit.target.wants/omarchy-system-factory-reset-finish.service" +} + +# Start the ESP's limine.conf over from the shipped template and drop the +# previous Omarchy identity's state. limine-entry-tool keys OS entries by +# machine-id, so after a reset gives the machine a fresh identity, the +# previous system's entry (with the now-stale UKI hash) would survive, sort +# first, and make Limine refuse the rebuilt UKI with a hash-mismatch warning. +# Only machine-ids the old limine.conf referenced are removed — a shared ESP +# may hold other installations' machine-id directories. +reset_limine_config() { + local root="$1" esp="$2" template found="" + + local old_ids="" + if [[ -f $root$esp/limine.conf ]]; then + old_ids=$(grep -o 'machine-id=[0-9a-f]\{32\}' "$root$esp/limine.conf" | cut -d= -f2 | sort -u) + fi + + for template in "$root/usr/share/omarchy/install/assets/limine/limine.conf" \ + "$root/usr/share/omarchy/default/limine/limine.conf"; do + if [[ -f $template ]]; then + cp "$template" "$root$esp/limine.conf" + found=1 + break + fi + done + [[ -n $found ]] || fail "no limine.conf template in $root/usr/share/omarchy" + + local machine_id old_id + machine_id=$(cat "$root/etc/machine-id" 2>/dev/null || true) + for old_id in $old_ids; do + [[ $old_id == "$machine_id" ]] && continue + rm -rf "${root:?}$esp/$old_id" + done +} + +# Every UKI entry in limine.conf carries a #blake2b hash of the file it +# points at; a mismatch makes Limine stop at a scary warning. Never hand over +# a staged reset in that state. +verify_limine_hashes() { + local root="$1" esp="$2" line path hash file + while IFS= read -r line; do + path=${line#*boot():} + path=${path%%#*} + hash=${line##*#} + file="$root$esp$path" + [[ -f $file ]] || fail "limine.conf points at missing $path" + [[ $(b2sum "$file" | cut -d' ' -f1) == "$hash" ]] || + fail "limine.conf hash for $path does not match the rebuilt file" + done < <(grep -o 'boot():/EFI/Linux/[^#]*#[0-9a-f]*' "$root$esp/limine.conf") +} + +# Rebuild the UKI from inside the next root so the deployed kernel/initramfs +# match the factory root's modules (the seller may have updated kernels since +# install) and embed the auto-unlock keyfile on encrypted machines. +rebuild_next_boot() { + local next="$1" + + local esp_source esp_mount + esp_mount=$(awk '$3 == "vfat" && $2 ~ /^\/(boot|efi)/ { print $2; exit }' "$next/etc/fstab") + esp_source=$(awk '$3 == "vfat" && $2 ~ /^\/(boot|efi)/ { print $1; exit }' "$next/etc/fstab") + [[ -n $esp_mount && -n $esp_source ]] || fail "could not find the ESP in $next/etc/fstab" + + local esp_device=$esp_source + if [[ $esp_source == UUID=* ]]; then + esp_device="/dev/disk/by-uuid/${esp_source#UUID=}" + fi + [[ -e $esp_device ]] || fail "ESP device $esp_device not found" + + mount "$esp_device" "$next$esp_mount" + + local dir + for dir in proc sys dev run; do + mount --rbind "/$dir" "$next/$dir" + mount --make-rslave "$next/$dir" + done + + # Btrfs snapshots do not recurse into nested subvolumes, so the factory + # root carries only an empty /swap directory where the hibernation swapfile + # lived. Recreate it and refresh the resume offset before the UKI build + # bakes the cmdline; the empty placeholder directory has to go first so the + # subvolume can be recreated. + if [[ -x $next/usr/bin/omarchy-hibernation-setup ]] && grep -q "/swap/swapfile" "$next/etc/fstab"; then + log "Recreating the hibernation swapfile in the factory system" + if [[ -d $next/swap ]] && ! btrfs subvolume show "$next/swap" >/dev/null 2>&1; then + rmdir "$next/swap" 2>/dev/null || true + fi + # The factory root still carries the resume drop-ins from install, which + # make hibernation-setup conclude it has nothing to do; with the swapfile + # gone they must go so it reconfigures from scratch. The resume-offset + # drop-in especially: hibernation-setup only recomputes the physical + # offset when it is absent, so a surviving one bakes the old swapfile's + # offset into the rebuilt UKI and breaks resume. + if [[ ! -f $next/swap/swapfile ]]; then + rm -f "$next/etc/mkinitcpio.conf.d/omarchy_resume.conf" \ + "$next/etc/limine-entry-tool.d/resume.conf" + fi + if ! chroot "$next" env OMARCHY_PATH=/usr/share/omarchy \ + /usr/bin/omarchy-hibernation-setup --force --no-rebuild >>"$LOG_FILE" 2>&1; then + fail "hibernation setup failed in the factory root (see $LOG_FILE)" + fi + [[ -f $next/swap/swapfile ]] || + fail "hibernation setup did not recreate $next/swap/swapfile (see $LOG_FILE)" + fi + + reset_limine_config "$next" "$esp_mount" + + log "Rebuilding boot files from the factory system (this can take a minute)" + if ! chroot "$next" /usr/bin/limine-update >>"$LOG_FILE" 2>&1; then + fail "limine-update failed in the factory root (see $LOG_FILE)" + fi + + verify_limine_hashes "$next" "$esp_mount" + + for dir in run dev sys proc; do + umount -R "$next/$dir" 2>/dev/null || true + done + umount "$next$esp_mount" +} + +# Remove the seller's account material and machine identity from the retained +# @factory baseline so it can neither be mounted for recovery nor restore the +# seller's account on a future reset. Idempotent (a scrubbed baseline has no +# uid>=1000 accounts left to remove). +sanitize_factory_baseline() { + local factory="$1" user + btrfs property set -ts "$factory" ro false + + for user in $(awk -F: '$3 >= 1000 && $3 < 60000 { print $1 }' "$factory/etc/passwd"); do + userdel --root "$factory" "$user" 2>>"$LOG_FILE" || true + rm -rf "${factory:?}/home/$user" + done + passwd --root "$factory" --lock root >>"$LOG_FILE" 2>&1 || true + rm -f "$factory"/etc/ssh/ssh_host_* + rm -f "$factory"/etc/NetworkManager/system-connections/* + rm -rf "$factory"/var/lib/NetworkManager/* "$factory/var/lib/tailscale" "$factory/var/lib/iwd" + rm -f "$factory/var/lib/sddm/state.conf" "$factory/etc/sddm.conf.d/autologin.conf" + : >"$factory/etc/machine-id" + + btrfs property set -ts "$factory" ro true +} + +stage_full_reset() { + local top="$TOP_MNT" next="$TOP_MNT/$NEXT_NAME" + + log "Cloning the factory snapshot" + [[ -d $next ]] && btrfs subvolume delete --recursive "$next" >/dev/null + btrfs subvolume snapshot "$top/@factory" "$next" >>"$LOG_FILE" + + local unit_src="$next/usr/share/omarchy/install/provisioning" + [[ -f $unit_src/omarchy-provision-owner.service && -x $next/usr/bin/omarchy-provision-owner ]] || + fail "the factory snapshot predates provisioning support; cannot reset from it" + + log "Scrubbing machine identity from the factory system" + systemd-id128 new >"$next/etc/machine-id" + rm -f "$next"/etc/ssh/ssh_host_* + rm -f "$next"/etc/NetworkManager/system-connections/* + rm -rf "$next"/var/lib/NetworkManager/* "$next/var/lib/tailscale" "$next/var/lib/iwd" + rm -f "$next/var/lib/sddm/state.conf" "$next/etc/sddm.conf.d/autologin.conf" + + # A factory snapshot from a normal (normal) install contains the original + # user account; first-boot setup must start from none. A leftover account + # would keep its password hash and group memberships (including wheel), so + # failure here has to abort the reset, not be shrugged off. + local user + for user in $(awk -F: '$3 >= 1000 && $3 < 60000 { print $1 }' "$next/etc/passwd"); do + log "Removing user $user from the factory system" + userdel --root "$next" "$user" 2>>"$LOG_FILE" || + fail "could not remove user $user from the factory system (see $LOG_FILE)" + done + passwd --root "$next" --lock root >>"$LOG_FILE" 2>&1 || true + + # @factory itself survives the wipe as the baseline for future resets. If it + # came from a normal install it still holds the seller's account and + # /etc/shadow, which the new wheel user could mount and read — and a second + # reset would restore that account. Scrub it once, in place. + sanitize_factory_baseline "$top/@factory" + + # Keep the Node tarball reachable for offline first-boot finalization. + if ! compgen -G "$next$PROVISIONING_DIR/packages/node-v*.tar.gz" >/dev/null; then + if compgen -G "$PROVISIONING_DIR/packages/node-v*.tar.gz" >/dev/null; then + install -d -m 755 "$next$PROVISIONING_DIR/packages" + cp "$PROVISIONING_DIR"/packages/node-v*.tar.gz "$next$PROVISIONING_DIR/packages/" + fi + fi + + install -d -m 755 "$next$PROVISIONING_DIR" + touch "$next$PROVISIONING_DIR/pending" "$next$PROVISIONING_DIR/wipe-pending" + + install_provisioning_units "$next" "$unit_src" + + if encrypted_install; then + stage_luks_rekey "$next" + fi + + rebuild_next_boot "$next" + + log "Activating the factory system" + local old="@omarchy-old-$(date +%s)" + mv "$top/@" "$top/$old" + mv "$next" "$top/@" + swap_done=1 + sync +} + +stage_degraded_reset() { + local unit_src="$OMARCHY_PATH/install/provisioning" + [[ -f $unit_src/omarchy-provision-owner.service && -x /usr/bin/omarchy-provision-owner ]] || + fail "this Omarchy version does not ship provisioning; update first" + + # Fallible staging (re-key, boot rebuild) happens before the wipe is armed: + # a failure must leave the machine untouched, not schedule a wipe for a + # reset that never finished staging. The degraded path stages into the LIVE + # root, so a mid-staging failure must also undo the auto-unlock material it + # already wrote — otherwise a later boot rebuild would silently embed it. + install -d -m 755 "$PROVISIONING_DIR" + if encrypted_install; then + degraded_rekey_staged=1 + stage_luks_rekey "" + log "Rebuilding boot files with the provisioning unlock key" + if ! limine-update >>"$LOG_FILE" 2>&1; then + fail "limine-update failed (see $LOG_FILE)" + fi + degraded_rekey_staged=0 + fi + + install_provisioning_units "" "$unit_src" + touch "$PROVISIONING_DIR/pending" "$PROVISIONING_DIR/wipe-pending" "$PROVISIONING_DIR/wipe-degraded" + + swap_done=1 +} + +# Undo the live-root auto-unlock material a failed degraded rekey left behind. +rollback_degraded_rekey() { + (( ${degraded_rekey_staged:-0} )) || return 0 + rm -f /etc/omarchy/provisioning.key "$PROVISIONING_DIR/luks-key" \ + /etc/limine-entry-tool.d/99-omarchy-provisioning-unlock.conf \ + /etc/mkinitcpio.conf.d/99-omarchy-provisioning-key.conf + limine-update >>"$LOG_FILE" 2>&1 || true +} + +main() { + command -v gum >/dev/null || { echo "Error: gum is required" >&2; exit 1; } + command -v btrfs >/dev/null || { echo "Error: btrfs-progs is required" >&2; exit 1; } + + root_is_btrfs_at || fail "reset requires the standard Omarchy Btrfs layout (subvol=@)" + + touch "$LOG_FILE" + chmod 600 "$LOG_FILE" + swap_done=0 + + local device + device=$(root_device) + [[ -n $device ]] || fail "could not determine the root device" + + mkdir -p "$TOP_MNT" + mountpoint -q "$TOP_MNT" || mount -o subvolid=5 "$device" "$TOP_MNT" + + local degraded=false + [[ -d $TOP_MNT/@factory ]] || degraded=true + + confirm_reset "$degraded" + + # The running system's limine-snapper-sync must not rewrite the ESP's + # limine.conf behind the staged rebuild (subvolume changes below can + # trigger it). The runtime mask evaporates on the reboot that follows. + systemctl mask --runtime --now limine-snapper-sync.service >/dev/null 2>&1 || true + systemctl mask --runtime --now limine-snapper-sync.path >/dev/null 2>&1 || true + + if [[ $degraded == true ]]; then + stage_degraded_reset + else + stage_full_reset + fi + + umount -R "$TOP_MNT" 2>/dev/null || true + + echo + gum style --bold "Reset staged. The wipe finishes on the next boot." + if gum confirm --affirmative "Reboot now" --negative "Reboot later" "Reboot to complete the reset?"; then + systemctl reboot + else + gum style --foreground 3 "Do not keep using this machine — changes made now will be lost." + fi +} + +main "$@" diff --git a/bin/omarchy-system-factory-reset-finish b/bin/omarchy-system-factory-reset-finish new file mode 100755 index 00000000..cc46c319 --- /dev/null +++ b/bin/omarchy-system-factory-reset-finish @@ -0,0 +1,171 @@ +#!/bin/bash + +# omarchy:summary=First-boot worker that finishes an omarchy-system-factory-reset reset +# omarchy:group=setup +# omarchy:requires-sudo=true +# omarchy:examples=omarchy-system-factory-reset-finish + +# Runs once, early on the first boot after omarchy-system-factory-reset, via +# omarchy-system-factory-reset-finish.service (armed by /var/lib/omarchy/provisioning/wipe-pending). +# Ordered before home.mount/var-log.mount so @home and @log can be dropped and +# recreated as empty subvolumes instead of rm -rf'd file by file. +# +# Two modes: +# - full reset: the root is already a fresh clone of @factory (staged by +# omarchy-system-factory-reset); this deletes the previous root (@omarchy-old-*), +# recreates @home/@log, and repairs /.snapshots. +# - degraded (wipe-degraded marker): machines installed before @factory +# existed keep their current root; user accounts and identity state are +# scrubbed here instead. + +set -uo pipefail + +PROVISIONING_DIR=/var/lib/omarchy/provisioning +TOP_MNT=/run/omarchy-system-factory-reset-finish-top + +[[ -f $PROVISIONING_DIR/wipe-pending ]] || exit 0 + +if (( EUID != 0 )); then + echo "Error: omarchy-system-factory-reset-finish must run as root" >&2 + exit 1 +fi + +# This runs before var-log.mount, so a log file under /var/log would be +# shadowed (and @log is recreated below anyway). stdout goes to the journal, +# which is volatile in /run this early and flushed into the fresh @log later. +echo "=== Omarchy factory wipe started: $(date '+%Y-%m-%d %H:%M:%S') ===" + +log() { + echo "factory-wipe: $1" +} + +root_device() { + findmnt -no SOURCE / | sed 's/\[.*\]//' +} + +abort() { + # Keep wipe-pending so the wipe retries next boot, and so provisioning stays + # gated (it refuses to create a user on a half-wiped system). + log "$1 — aborting; the wipe will retry on the next boot" + umount "$TOP_MNT" 2>/dev/null || true + rmdir "$TOP_MNT" 2>/dev/null || true + exit 1 +} + +delete_subvolume() { + local path="$1" + [[ -d $path ]] || return 0 + + if ! btrfs subvolume delete --recursive "$path" 2>/dev/null; then + # Older btrfs-progs without --recursive: delete nested subvolumes deepest + # first, then the subvolume itself. + local nested + nested=$(btrfs subvolume list -o "$path" 2>/dev/null | awk '{print $NF}') + if [[ -n $nested ]]; then + local sub + while IFS= read -r sub; do + delete_subvolume "$TOP_MNT/${sub#/}" + done <<<"$nested" + fi + btrfs subvolume delete "$path" + fi +} + +recreate_subvolume() { + local name="$1" + delete_subvolume "$TOP_MNT/$name" + btrfs subvolume create "$TOP_MNT/$name" +} + +scrub_degraded_state() { + log "degraded reset: scrubbing user accounts and machine identity in place" + + local user + for user in $(awk -F: '$3 >= 1000 && $3 < 60000 { print $1 }' /etc/passwd); do + log "removing user $user" + rm -rf "/var/lib/fprint/$user" # enrolled fingerprints outlive userdel + userdel "$user" 2>/dev/null || abort "could not remove user $user" + done + + rm -f /etc/ssh/ssh_host_* + rm -f /etc/NetworkManager/system-connections/* + rm -rf /var/lib/NetworkManager/* /var/lib/tailscale /var/lib/iwd + rm -f /var/lib/sddm/state.conf /etc/sddm.conf.d/autologin.conf + + # Fresh machine identity from the next boot on. + systemd-id128 new >/etc/machine-id 2>/dev/null || :>/etc/machine-id +} + +repair_snapshots_dir() { + # A snapshot of @ carries /.snapshots only as a plain empty directory + # (nested subvolumes are not part of snapshots). Snapper needs it to be a + # subvolume again. + if [[ -d /.snapshots ]] && ! btrfs subvolume show /.snapshots >/dev/null 2>&1; then + rm -rf /.snapshots + fi + if [[ ! -d /.snapshots ]]; then + btrfs subvolume create /.snapshots + chmod 750 /.snapshots + fi +} + +wipe_snapper_snapshots() { + # Degraded resets keep the current root, so its accumulated snapper + # snapshots (nested under /.snapshots) still exist. Full resets already lost + # them with the old root. + local snapshot + for snapshot in /.snapshots/*/snapshot; do + [[ -d $snapshot ]] || continue + delete_subvolume "$snapshot" + rm -rf "$(dirname "$snapshot")" + done +} + +main() { + local device + device=$(root_device) + if [[ -z $device ]]; then + log "could not determine the btrfs root device; aborting" + exit 1 + fi + + mkdir -p "$TOP_MNT" + if ! mount -o subvolid=5 "$device" "$TOP_MNT"; then + log "could not mount the btrfs top level from $device; aborting" + exit 1 + fi + + if [[ -f $PROVISIONING_DIR/wipe-degraded ]]; then + scrub_degraded_state + wipe_snapper_snapshots + fi + + local old + for old in "$TOP_MNT"/@omarchy-old-*; do + [[ -d $old ]] || continue + log "deleting previous system root $(basename "$old")" + delete_subvolume "$old" || abort "could not delete $(basename "$old")" + done + + # A wipe that cannot recreate @home has not wiped anything — the seller's + # data would survive a "successful" factory reset. + log "recreating @home and @log" + recreate_subvolume @home || abort "could not recreate @home" + recreate_subvolume @log || abort "could not recreate @log" + + repair_snapshots_dir + + umount "$TOP_MNT" + rmdir "$TOP_MNT" 2>/dev/null || true + + log "trimming free space" + fstrim -a 2>/dev/null || true + + rm -f "$PROVISIONING_DIR/wipe-pending" "$PROVISIONING_DIR/wipe-degraded" + rm -f /etc/systemd/system/sysinit.target.wants/omarchy-system-factory-reset-finish.service + rm -f /etc/systemd/system/omarchy-system-factory-reset-finish.service + + log "factory wipe complete" +} + +main diff --git a/default/hypr/autostart.lua b/default/hypr/autostart.lua index 079ca18e..d6797290 100644 --- a/default/hypr/autostart.lua +++ b/default/hypr/autostart.lua @@ -4,7 +4,7 @@ hl.on("hyprland.start", function() hl.exec_cmd("dbus-update-activation-environment --systemd --all") hl.exec_cmd("omarchy-launch-shell") - hl.exec_cmd("omarchy-first-run") + hl.exec_cmd("omarchy-provision-first-run") hl.exec_cmd("omarchy-powerprofiles-init") hl.exec_cmd(o.launch("omarchy-hyprland-monitor-watch")) hl.exec_cmd(o.launch("udiskie --automount --no-notify --no-tray")) diff --git a/default/omarchy/omarchy-menu.jsonc b/default/omarchy/omarchy-menu.jsonc index 55019be2..e582e4ca 100644 --- a/default/omarchy/omarchy-menu.jsonc +++ b/default/omarchy/omarchy-menu.jsonc @@ -173,6 +173,7 @@ "setup.config.hyprsunset": {"icon":"","label":"Hyprsunset","action":"omarchy-launch-config-editor ~/.config/hypr/hyprsunset.conf && omarchy-restart-hyprsunset"}, "setup.config.xcompose": {"icon":"󰞅","label":"XCompose","action":"omarchy-launch-config-editor ~/.XCompose && omarchy-restart-xcompose"}, "setup.direct-boot": {"icon":"","label":"Direct Boot","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-direct-boot"}, + "setup.reset": {"icon":"󰑓","label":"Reset Computer","when":"[[ $(findmnt -no FSTYPE /) == btrfs ]]","action":"omarchy-launch-floating-terminal-with-presentation omarchy-system-factory-reset"}, // Install "install.package": {"icon":"󰣇","label":"Package","action":"xdg-terminal-exec --app-id=org.omarchy.terminal omarchy-pkg-install"}, diff --git a/docs/file-layout.md b/docs/file-layout.md index 99370089..0569e702 100644 --- a/docs/file-layout.md +++ b/docs/file-layout.md @@ -219,7 +219,7 @@ files are missing. `omarchy update` runs `omarchy-migrate` after the package transaction in the already-visible update terminal, then runs `omarchy-hook post-update`. -## First-run (`omarchy-first-run`) +## First-run (`omarchy-provision-first-run`) Runs once on first interactive login, after the user manager is live. Used for steps that need a running graphical session and/or a working user diff --git a/docs/update-process.md b/docs/update-process.md index 0810fa68..f994292d 100644 --- a/docs/update-process.md +++ b/docs/update-process.md @@ -186,7 +186,7 @@ lock, silencing migration notifications at every login. Fallbacks: -- `omarchy-first-run` enables `omarchy-migrate-notify.service`, which also +- `omarchy-provision-first-run` enables `omarchy-migrate-notify.service`, which also covers users created after install: their per-user migration markers are missing, so their first login prompts them to run every shipped migration. - The package ships `omarchy-update-user-notify.service` as a symlink onto diff --git a/install/config/docker.sh b/install/config/docker.sh index 00604855..1dc17e81 100644 --- a/install/config/docker.sh +++ b/install/config/docker.sh @@ -1 +1,10 @@ -usermod -aG docker "$OMARCHY_INSTALL_USER" +# Record the docker group for provisioning first-boot user creation and factory reset, +# then grant it directly when the install user already exists (deferred-provisioning +# installs create the user at first boot instead). +provisioning_dir="${OMARCHY_PROVISIONING_DIR:-/var/lib/omarchy/provisioning}" +mkdir -p "$provisioning_dir" +grep -qxF docker "$provisioning_dir/groups" 2>/dev/null || echo docker >>"$provisioning_dir/groups" + +if [[ -n ${OMARCHY_INSTALL_USER:-} ]] && getent passwd "$OMARCHY_INSTALL_USER" >/dev/null; then + usermod -aG docker "$OMARCHY_INSTALL_USER" +fi diff --git a/install/hardware/input-group.sh b/install/hardware/input-group.sh index 2180af1a..fac87fcd 100644 --- a/install/hardware/input-group.sh +++ b/install/hardware/input-group.sh @@ -1,2 +1,11 @@ -# Give this user privileged input access for dictation tools + xbox controllers to work -usermod -aG input "$OMARCHY_INSTALL_USER" +# Give this user privileged input access for dictation tools + xbox controllers to work. +# Recorded for provisioning first-boot user creation and factory reset, granted directly +# when the install user already exists (deferred-provisioning installs create the user at +# first boot instead). +provisioning_dir="${OMARCHY_PROVISIONING_DIR:-/var/lib/omarchy/provisioning}" +mkdir -p "$provisioning_dir" +grep -qxF input "$provisioning_dir/groups" 2>/dev/null || echo input >>"$provisioning_dir/groups" + +if [[ -n ${OMARCHY_INSTALL_USER:-} ]] && getent passwd "$OMARCHY_INSTALL_USER" >/dev/null; then + usermod -aG input "$OMARCHY_INSTALL_USER" +fi diff --git a/install/provisioning/omarchy-provision-owner.service b/install/provisioning/omarchy-provision-owner.service new file mode 100644 index 00000000..a74985a2 --- /dev/null +++ b/install/provisioning/omarchy-provision-owner.service @@ -0,0 +1,35 @@ +# First-boot provisioning: asks for the user on tty1 before SDDM starts. +# Armed by /var/lib/omarchy/provisioning/pending (written by a deferred-provisioning ISO install or +# omarchy-system-factory-reset); installed to /etc/systemd/system by whichever of +# those staged it. Not shipped enabled — a normal install never runs it. + +# plymouth is dismissed via ExecStartPre rather than ordered against +# (plymouth-quit-wait), keeping this unit free of ordering-cycle risk with +# display-manager.service. +# The wipe-pending condition gates against a failed factory wipe: After= is +# ordering only, and running user setup on a half-wiped system would create +# the new account on state a wipe retry later deletes. While wipe-pending +# remains, setup stays down and the wipe retries on the next boot. +[Unit] +Description=Omarchy provisioning first-boot user setup +ConditionPathExists=/var/lib/omarchy/provisioning/pending +ConditionPathExists=!/var/lib/omarchy/provisioning/wipe-pending +After=systemd-user-sessions.service omarchy-system-factory-reset-finish.service +Before=display-manager.service getty@tty1.service +Conflicts=getty@tty1.service + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStartPre=-/usr/bin/plymouth quit +ExecStart=/usr/bin/omarchy-provision-owner +StandardInput=tty +StandardOutput=tty +StandardError=tty +TTYPath=/dev/tty1 +TTYReset=yes +TTYVHangup=yes +TTYVTDisallocate=yes + +[Install] +WantedBy=multi-user.target diff --git a/install/provisioning/omarchy-system-factory-reset-finish.service b/install/provisioning/omarchy-system-factory-reset-finish.service new file mode 100644 index 00000000..6909d660 --- /dev/null +++ b/install/provisioning/omarchy-system-factory-reset-finish.service @@ -0,0 +1,21 @@ +# Finishes an omarchy-system-factory-reset reset on the first boot after it: drops +# the previous root, recreates @home/@log, and scrubs state. Ordered before +# home.mount/var-log.mount so those subvolumes can be deleted and recreated +# while unmounted. Armed by /var/lib/omarchy/provisioning/wipe-pending; installed to +# /etc/systemd/system by omarchy-system-factory-reset. + +[Unit] +Description=Omarchy factory reset wipe +DefaultDependencies=no +ConditionPathExists=/var/lib/omarchy/provisioning/wipe-pending +After=systemd-remount-fs.service +Before=sysinit.target home.mount var-log.mount var-cache-pacman-pkg.mount shutdown.target +Conflicts=shutdown.target + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart=/usr/bin/omarchy-system-factory-reset-finish + +[Install] +WantedBy=sysinit.target diff --git a/install/user/mise-work.sh b/install/user/mise-work.sh index d7799f50..4612c4a7 100644 --- a/install/user/mise-work.sh +++ b/install/user/mise-work.sh @@ -9,19 +9,35 @@ EOF mise trust ~/Work/.mise.toml -if [[ ${OMARCHY_SETUP_CONTEXT:-runtime} == "iso-chroot" ]]; then - NODE_TARBALL=$(find /opt/packages -name "node-v*-linux-x64.tar.gz" -type f 2>/dev/null | head -n1) +# Offline installs unpack the Node tarball bundled by the ISO: from +# /opt/packages in the ISO chroot, or from the copy staged in provisioning state when +# omarchy-provision-owner finalizes the user at first boot. +case ${OMARCHY_SETUP_CONTEXT:-runtime} in + iso-chroot) NODE_PACKAGE_DIR=/opt/packages ;; + provision-owner) NODE_PACKAGE_DIR=/var/lib/omarchy/provisioning/packages ;; + *) NODE_PACKAGE_DIR="" ;; +esac + +if [[ -n $NODE_PACKAGE_DIR ]]; then + NODE_TARBALL=$(find "$NODE_PACKAGE_DIR" -name "node-v*-linux-x64.tar.gz" -type f 2>/dev/null | head -n1) if [[ -z $NODE_TARBALL ]]; then - echo "Error: bundled Node.js tarball missing from /opt/packages" >&2 - exit 1 + if [[ ${OMARCHY_SETUP_CONTEXT:-} == "provision-owner" ]]; then + # A degraded reset on an old install may not have the tarball staged. + # Leave Node to the network rather than failing the whole first boot. + echo "Warning: no bundled Node.js tarball in $NODE_PACKAGE_DIR; trying the network" >&2 + mise use -g node@latest || echo "Warning: Node.js install deferred (no network)" >&2 + else + echo "Error: bundled Node.js tarball missing from $NODE_PACKAGE_DIR" >&2 + exit 1 + fi + else + NODE_VERSION=$(basename "$NODE_TARBALL" | sed 's/node-v\(.*\)-linux-x64.tar.gz/\1/') + NODE_INSTALL_DIR="$HOME/.local/share/mise/installs/node/$NODE_VERSION" + + mkdir -p "$NODE_INSTALL_DIR" + tar -xzf "$NODE_TARBALL" --strip-components=1 -C "$NODE_INSTALL_DIR" + mise use -g node@"$NODE_VERSION" fi - - NODE_VERSION=$(basename "$NODE_TARBALL" | sed 's/node-v\(.*\)-linux-x64.tar.gz/\1/') - NODE_INSTALL_DIR="$HOME/.local/share/mise/installs/node/$NODE_VERSION" - - mkdir -p "$NODE_INSTALL_DIR" - tar -xzf "$NODE_TARBALL" --strip-components=1 -C "$NODE_INSTALL_DIR" - mise use -g node@"$NODE_VERSION" else mise use -g node@latest fi diff --git a/install/user/theme.sh b/install/user/theme.sh index e318f5d6..29f370d3 100644 --- a/install/user/theme.sh +++ b/install/user/theme.sh @@ -2,7 +2,8 @@ mkdir -p ~/.config/omarchy/themes if [[ ! -s $HOME/.local/state/omarchy/current/theme.name ]]; then - if [[ ${OMARCHY_SETUP_CONTEXT:-runtime} == "iso-chroot" ]]; then + # iso-chroot and provision-owner both run without a live session to notify. + if [[ ${OMARCHY_SETUP_CONTEXT:-runtime} != "runtime" ]]; then OMARCHY_THEME_HEADLESS=1 omarchy-theme-set "Tokyo Night" rm -f ~/.config/chromium/SingletonLock # otherwise archiso owns the Chromium singleton else diff --git a/test/shell.d/first-run-test.sh b/test/shell.d/first-run-test.sh index 83b87db4..0fd59375 100644 --- a/test/shell.d/first-run-test.sh +++ b/test/shell.d/first-run-test.sh @@ -12,20 +12,20 @@ cat >"$mock_bin/omarchy-done" <<'SH' #!/bin/bash [[ $1 == "check" && $2 == "first-run-user" ]] SH -cat >"$mock_bin/omarchy-finalize-user" <<'SH' +cat >"$mock_bin/omarchy-provision-user" <<'SH' #!/bin/bash touch "$OMARCHY_TEST_FINALIZE_CALLED" SH -chmod +x "$mock_bin/omarchy-done" "$mock_bin/omarchy-finalize-user" +chmod +x "$mock_bin/omarchy-done" "$mock_bin/omarchy-provision-user" finalize_called="$test_tmp/finalize-called" HOME="$test_tmp/home" PATH="$mock_bin:$PATH" OMARCHY_TEST_FINALIZE_CALLED="$finalize_called" \ - bash "$ROOT/bin/omarchy-first-run" >"$test_tmp/output" + bash "$ROOT/bin/omarchy-provision-first-run" >"$test_tmp/output" [[ ! -e $finalize_called ]] || fail "completed first-run exits before any setup step" grep -F 'First-run already complete' "$test_tmp/output" >/dev/null || fail "completed first-run reports its lifecycle gate" -if grep -F 'user-migration-notify-watch-enabled' "$ROOT/bin/omarchy-first-run" >/dev/null; then +if grep -F 'user-migration-notify-watch-enabled' "$ROOT/bin/omarchy-provision-first-run" >/dev/null; then fail "first-run does not track the migration watcher separately" fi if grep -F 'skip-first-run-update-notification' "$ROOT/install/user/first-run/wifi.sh" >/dev/null; then diff --git a/test/shell.d/menu-test.sh b/test/shell.d/menu-test.sh index 7a5d2492..3c9fd9a4 100644 --- a/test/shell.d/menu-test.sh +++ b/test/shell.d/menu-test.sh @@ -180,11 +180,15 @@ assert( defaultById['setup.direct-boot'].action.includes('omarchy-setup-direct-boot'), 'menu places Direct Boot directly under Setup' ) +assert( + defaultById['setup.reset'].action.includes('omarchy-system-factory-reset'), + 'menu exposes Reset Computer under Setup' +) const setupEntries = defaultItems.filter(item => item.parent === 'setup') assertEqual( setupEntries[setupEntries.length - 1].id, - 'setup.direct-boot', - 'menu lists Direct Boot last under Setup' + 'setup.reset', + 'menu lists Reset Computer last under Setup' ) const expectedAgents = { pi: { icon: '\ue901', iconFont: 'omarchy', label: 'Pi' }, diff --git a/test/shell.d/provisioning-groups-test.sh b/test/shell.d/provisioning-groups-test.sh new file mode 100644 index 00000000..23a8ef3a --- /dev/null +++ b/test/shell.d/provisioning-groups-test.sh @@ -0,0 +1,55 @@ +#!/bin/bash +# +# The install scripts that grant group memberships must record them in the provisioning +# groups file (for first-boot user creation and factory reset) and only call +# usermod when the install user actually exists. + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +TMPDIR=$(mktemp -d) +trap 'rm -rf "$TMPDIR"' EXIT + +export OMARCHY_PROVISIONING_DIR="$TMPDIR/provisioning" + +# Stub getent/usermod: the fake system knows only the user "existing". +mkdir -p "$TMPDIR/bin" +cat >"$TMPDIR/bin/getent" <<'EOF' +#!/bin/bash +[[ $1 == passwd && $2 == existing ]] && { echo "existing:x:1000:1000::/home/existing:/bin/bash"; exit 0; } +exit 2 +EOF +cat >"$TMPDIR/bin/usermod" <>"$TMPDIR/usermod.calls" +EOF +chmod +x "$TMPDIR/bin/getent" "$TMPDIR/bin/usermod" +export PATH="$TMPDIR/bin:$PATH" + +# No install user (deferred-provisioning install): groups recorded, usermod not called. +OMARCHY_INSTALL_USER="" bash -eE "$ROOT/install/config/docker.sh" +OMARCHY_INSTALL_USER="" bash -eE "$ROOT/install/hardware/input-group.sh" + +[[ -f $OMARCHY_PROVISIONING_DIR/groups ]] || fail "groups file written without an install user" +grep -qxF docker "$OMARCHY_PROVISIONING_DIR/groups" || fail "docker group recorded" +grep -qxF input "$OMARCHY_PROVISIONING_DIR/groups" || fail "input group recorded" +[[ ! -f $TMPDIR/usermod.calls ]] || fail "usermod not called without an install user" +pass "deferred provisioning records groups without calling usermod" + +# Missing user (defensive): no usermod either. +OMARCHY_INSTALL_USER=ghost bash -eE "$ROOT/install/config/docker.sh" +[[ ! -f $TMPDIR/usermod.calls ]] || fail "usermod not called for a missing user" +pass "missing install user defers group grants" + +# Re-running never duplicates entries. +OMARCHY_INSTALL_USER="" bash -eE "$ROOT/install/config/docker.sh" +[[ $(grep -cxF docker "$OMARCHY_PROVISIONING_DIR/groups") == 1 ]] || fail "docker group recorded once" +pass "group recording is idempotent" + +# Existing user: usermod applies the groups and the record still lands. +OMARCHY_INSTALL_USER=existing bash -eE "$ROOT/install/config/docker.sh" +OMARCHY_INSTALL_USER=existing bash -eE "$ROOT/install/hardware/input-group.sh" +grep -qx -- "-aG docker existing" "$TMPDIR/usermod.calls" || fail "usermod grants docker to the install user" +grep -qx -- "-aG input existing" "$TMPDIR/usermod.calls" || fail "usermod grants input to the install user" +pass "existing install user still gets direct group grants"