Files
omarchycn/bin/omarchy-system-factory-reset
T
David Heinemeier HanssonandClaude Opus 5 f228c4d390 Stop checking for commands the package set guarantees
gum and ttfx ship in omarchy-base.packages and setfont comes with kbd, so
none of them needs a presence check — and every setfont call already
tolerates a failure anyway. btrfs-progs is in omarchy-other.packages and a
reset genuinely cannot proceed without it, so that one keeps its guard and
moves to the helper.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 03:31:17 -07:00

480 lines
19 KiB
Bash
Executable File

#!/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() {
omarchy-cmd-present btrfs || { 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 "$@"