Merge pull request #9467 from AFOliveira/codex/om-sec-12-update-inhibitor-identity
[codex] OM-SEC-12: Bind update inhibitor cleanup to process identity Reported-by: Afonso "AFOliveira" Oliveira
This commit is contained in:
38 files changed
+3548
-422
No files matched your search
+52
-5
@@ -1,10 +1,25 @@
|
||||
#!/bin/bash
|
||||
#!/bin/bash -p
|
||||
|
||||
# omarchy:summary=Set the Omarchy package channel.
|
||||
# omarchy:args=<stable|rc|edge|dev>
|
||||
# omarchy:requires-sudo=true
|
||||
|
||||
if [[ $- != *p* ]]; then
|
||||
echo "Refusing an unsafe Bash startup for channel switching." >&2
|
||||
exit 126
|
||||
fi
|
||||
security_entrypoint=$(/usr/bin/readlink -e -- "${BASH_SOURCE[0]}") || exit 126
|
||||
source "${security_entrypoint%/*}/omarchy-security-functions" || exit 126
|
||||
omarchy_security_require_privileged_bash_startup || exit 126
|
||||
set -euo pipefail
|
||||
omarchy_security_sanitize_bash_environment "$0" "$@"
|
||||
omarchy_security_require_source_root "$0"
|
||||
user_path=$PATH
|
||||
# Traps first, so a signal or failure during the entry revocation still
|
||||
# exits through the cleanup path.
|
||||
omarchy_security_install_sudo_cleanup_traps
|
||||
omarchy_security_revoke_sudo_timestamp || exit 1
|
||||
omarchy_security_enable_no_update_sudo
|
||||
|
||||
usage() { echo "Usage: omarchy-channel-set [stable|rc|edge|dev]"; }
|
||||
fail() { echo "Error: $*" >&2; exit 1; }
|
||||
@@ -33,12 +48,40 @@ validate_dev_checkout() {
|
||||
}
|
||||
|
||||
link_dev_checkout() {
|
||||
local checkout="$1"
|
||||
local checkout="$1" required
|
||||
[[ -d $checkout/.git ]] || git clone https://github.com/omacom/omarchy.git "$checkout"
|
||||
|
||||
# Check the destination before changing /etc/omarchy.conf or sudo's path.
|
||||
# An existing checkout is not pulled automatically and may predate this policy.
|
||||
for required in bin/omarchy-security-functions bin/omarchy-update bin/omarchy-refresh-pacman default/omarchy/sudo-no-update/sudo; do
|
||||
if [[ ! -f $checkout/$required || ! -r $checkout/$required ||
|
||||
( $required != "bin/omarchy-security-functions" && ! -x $checkout/$required ) ]]; then
|
||||
fail "Update the checkout before switching to dev; missing required update support in $required."
|
||||
fi
|
||||
done
|
||||
|
||||
omarchy-dev-link "$checkout" --no-reboot
|
||||
}
|
||||
|
||||
# A packaged destination cannot be inspected before its package is installed,
|
||||
# and a package transaction can replace the running tree with a release that
|
||||
# predates the command-scoped wrapper. After that, a bare sudo would resolve to
|
||||
# /usr/bin/sudo and publish a timestamp, and the destination's own updater
|
||||
# authenticates the same way. Neither may run from a flow that has just run
|
||||
# user hooks: stop at a consistent point and say how to finish from a fresh
|
||||
# session.
|
||||
stop_for_older_destination() {
|
||||
cat >&2 <<EOF
|
||||
The destination predates command-scoped sudo, so this switch stops before its update.
|
||||
Packages are switched. Run 'omarchy update' from a new terminal to finish, then reboot if prompted.
|
||||
EOF
|
||||
exit 3
|
||||
}
|
||||
|
||||
wrapper_present() {
|
||||
[[ -f $OMARCHY_PATH/default/omarchy/sudo-no-update/sudo && -x $OMARCHY_PATH/default/omarchy/sudo-no-update/sudo ]]
|
||||
}
|
||||
|
||||
(( $# > 0 )) || { usage; exit 1; }
|
||||
|
||||
dev_checkout=""
|
||||
@@ -83,13 +126,16 @@ fi
|
||||
if [[ -n $dev_checkout ]]; then
|
||||
link_dev_checkout "$dev_checkout"
|
||||
export OMARCHY_PATH="$dev_checkout"
|
||||
export PATH="$OMARCHY_PATH/bin:$PATH"
|
||||
omarchy_security_enable_no_update_sudo
|
||||
omarchy-state set reboot-required
|
||||
fi
|
||||
|
||||
omarchy-refresh-pacman "$pacman_channel"
|
||||
OMARCHY_UPDATE_USER_PATH="$user_path" omarchy-refresh-pacman "$pacman_channel"
|
||||
# Each transaction can have replaced this tree; check before the next sudo.
|
||||
wrapper_present || stop_for_older_destination
|
||||
# --ask 4 accepts omarchy <-> omarchy-dev replacement prompts without file overwrites.
|
||||
omarchy-update-pacman -S --needed --noconfirm --ask 4 "${packages[@]}"
|
||||
wrapper_present || stop_for_older_destination
|
||||
|
||||
if [[ -z $dev_checkout ]]; then
|
||||
omarchy-dev-unlink --no-reboot
|
||||
@@ -98,6 +144,7 @@ if [[ -z $dev_checkout ]]; then
|
||||
if (( leaving_dev )); then
|
||||
omarchy-state set reboot-required
|
||||
fi
|
||||
omarchy_security_enable_no_update_sudo 2>/dev/null || stop_for_older_destination
|
||||
fi
|
||||
|
||||
omarchy-update -y
|
||||
OMARCHY_UPDATE_USER_PATH="$user_path" "$OMARCHY_PATH/bin/omarchy-update" -y
|
||||
+37
-13
@@ -1,26 +1,50 @@
|
||||
#!/bin/bash
|
||||
#!/bin/bash -p
|
||||
|
||||
# omarchy:summary=Overwrite the package configuration for /etc/pacman with the Omarchy default of using its dedicated mirrors and repositories, then update all packages.
|
||||
# omarchy:requires-sudo=true
|
||||
|
||||
sudo cp -f /etc/pacman.conf /etc/pacman.conf.bak
|
||||
sudo cp -f /etc/pacman.d/mirrorlist /etc/pacman.d/mirrorlist.bak
|
||||
|
||||
channel="${1:-stable}"
|
||||
|
||||
if [[ $channel != "stable" && $channel != "rc" && $channel != "edge" ]]; then
|
||||
echo "Error: Invalid channel '$channel'. Must be one of: stable, rc, edge"
|
||||
exit 1
|
||||
if [[ $- != *p* ]]; then
|
||||
echo "Refusing an unsafe Bash startup." >&2
|
||||
exit 126
|
||||
fi
|
||||
|
||||
echo "Setting channel to $channel"
|
||||
echo
|
||||
security_entrypoint=$(/usr/bin/readlink -e -- "${BASH_SOURCE[0]}") || exit 126
|
||||
source "${security_entrypoint%/*}/omarchy-security-functions" || exit 126
|
||||
omarchy_security_require_privileged_bash_startup || exit 126
|
||||
set -e
|
||||
omarchy_security_sanitize_bash_environment "$0" "$@"
|
||||
omarchy_security_require_source_root "$0"
|
||||
# Channel switching calls this behind its own sanitized PATH and hands the
|
||||
# caller's original path over the same way the updater receives it.
|
||||
user_path=${OMARCHY_UPDATE_USER_PATH:-$PATH}
|
||||
unset OMARCHY_UPDATE_USER_PATH
|
||||
# Traps first, so a signal or failure during the entry revocation still
|
||||
# exits through the cleanup path.
|
||||
omarchy_security_install_sudo_cleanup_traps
|
||||
omarchy_security_revoke_sudo_timestamp || exit 1
|
||||
omarchy_security_enable_no_update_sudo
|
||||
|
||||
channel="${1:-stable}"
|
||||
if [[ $channel != "stable" && $channel != "rc" && $channel != "edge" ]]; then
|
||||
echo "Invalid channel: $channel" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
sudo cp -f /etc/pacman.conf /etc/pacman.conf.bak
|
||||
sudo cp -f /etc/pacman.d/mirrorlist /etc/pacman.d/mirrorlist.bak
|
||||
echo "Setting channel to $channel"
|
||||
sudo cp -f "$OMARCHY_PATH/default/pacman/pacman-$channel.conf" /etc/pacman.conf
|
||||
sudo cp -f "$OMARCHY_PATH/default/pacman/mirrorlist-$channel" /etc/pacman.d/mirrorlist
|
||||
|
||||
# Allow user customization of /etc/pacman.conf before the upgrade runs
|
||||
omarchy-hook pre-refresh-pacman
|
||||
# Allow user customization of /etc/pacman.conf before the upgrade runs, so
|
||||
# custom repositories and IgnorePkg entries shape the downgrade-capable
|
||||
# transaction below. The hook is user code: it runs cold behind the no-update
|
||||
# wrapper, and the timestamp is revoked again afterwards so any authorization
|
||||
# the hook obtained for itself cannot carry into the transaction.
|
||||
omarchy_security_revoke_sudo_timestamp
|
||||
PATH="$OMARCHY_PATH/default/omarchy/sudo-no-update:$user_path" \
|
||||
"$OMARCHY_PATH/bin/omarchy-hook" pre-refresh-pacman
|
||||
omarchy_security_revoke_sudo_timestamp
|
||||
|
||||
# Reset all package DBs and then update
|
||||
omarchy-update-pacman -Syyuu --noconfirm
|
||||
@@ -220,7 +220,7 @@ fi
|
||||
# never owned, a yes takes that with it, and saying so is the prompt's job.
|
||||
# Without a terminal to ask in, keeping everything is the answer.
|
||||
data_removed=false
|
||||
if [[ -d $HOME/.hermes || -d $HOME/.config/Hermes ]] && [[ -t 0 ]] && omarchy-cmd-present gum; then
|
||||
if [[ -d $HOME/.hermes || -d $HOME/.config/Hermes ]] && [[ -t 0 ]]; then
|
||||
# du answers non-zero when either directory is missing, and pipefail would
|
||||
# turn that into an aborted removal; the size is worth no such thing.
|
||||
size=$(du -shc "$HOME/.hermes" "$HOME/.config/Hermes" 2>/dev/null | tail -1 | cut -f1 || true)
|
||||
|
||||
@@ -61,7 +61,7 @@ gtk-update-icon-cache "$HOME/.local/share/icons/hicolor" &>/dev/null || true
|
||||
# front of the user with the size rather than left silent. Without a terminal
|
||||
# to ask in, keeping it is the answer.
|
||||
state_removed=false
|
||||
if [[ -d $HOME/.openclaw && -t 0 ]] && omarchy-cmd-present gum; then
|
||||
if [[ -d $HOME/.openclaw && -t 0 ]]; then
|
||||
size=$(du -sh "$HOME/.openclaw" 2>/dev/null | cut -f1)
|
||||
if gum confirm --default=false "Also delete ~/.openclaw ($size: chats, memories, credentials, and downloaded plugins)?"; then
|
||||
rm -rf "$HOME/.openclaw"
|
||||
|
||||
+56
-16
@@ -61,6 +61,21 @@ relock_session() {
|
||||
return 1
|
||||
}
|
||||
|
||||
# A short timeout keeps an unresponsive user bus from stalling the restart;
|
||||
# busctl would otherwise wait 25 seconds per probe.
|
||||
notifications_ready() {
|
||||
[[ $(busctl --user --timeout=1s call org.freedesktop.DBus /org/freedesktop/DBus \
|
||||
org.freedesktop.DBus NameHasOwner s org.freedesktop.Notifications 2>/dev/null) == "b true" ]]
|
||||
}
|
||||
|
||||
# Core IPC can answer before the notification plugin has registered its bus
|
||||
# name. Restore an existing notification service before update hooks or setup
|
||||
# invitations send their one-time toasts; a disabled service need not appear.
|
||||
notifications_were_running=0
|
||||
if notifications_ready; then
|
||||
notifications_were_running=1
|
||||
fi
|
||||
|
||||
# Each kill stops the oldest matching instance and only returns once it has
|
||||
# fully exited, so the no-duplicate launch below can't race a dying shell.
|
||||
while timeout 5 quickshell kill -p "$CONFIG_DIR" --any-display >/dev/null 2>&1; do :; done
|
||||
@@ -69,25 +84,50 @@ while timeout 5 quickshell kill -p "$CONFIG_DIR" --any-display >/dev/null 2>&1;
|
||||
# not transient variables from a terminal, SSH connection, or development tool.
|
||||
hyprctl dispatch 'hl.dsp.exec_cmd("omarchy-launch-shell")' >/dev/null
|
||||
|
||||
shell_ready=0
|
||||
for (( attempt = 0; attempt < 20; attempt++ )); do
|
||||
if OMARCHY_PATH="$session_omarchy_path" OMARCHY_SHELL_IPC_TIMEOUT=0.5s omarchy-shell shell ping >/dev/null 2>&1; then
|
||||
# The session stays compositor-locked after the old lock client died, so
|
||||
# re-acquire the lock and let the user authenticate out of it.
|
||||
if (( relock )) && ! relock_session; then
|
||||
echo "Omarchy shell restarted, but the session lock was not re-secured." >&2
|
||||
exit 1
|
||||
fi
|
||||
# Invitation toasts (like Voxtype/fingerprint setup) die with the old
|
||||
# shell, and their notify-send waiters hang forever: the dying server
|
||||
# never emits NotificationClosed. A still-running omarchy-*-invitation
|
||||
# unit is therefore an unanswered invitation — re-run it so its toast
|
||||
# reappears on the new shell. Answered invitations have already exited
|
||||
# and been collected, so the glob no longer matches them.
|
||||
systemctl --user try-restart 'omarchy-*-invitation.service' 2>/dev/null || true
|
||||
exit 0
|
||||
shell_ready=1
|
||||
break
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
if (( shell_ready == 0 )); then
|
||||
echo "Omarchy shell did not become ready after restart." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Omarchy shell did not become ready after restart." >&2
|
||||
exit 1
|
||||
# The session stays compositor-locked after the old lock client died, so
|
||||
# re-acquire the lock and let the user authenticate out of it. This comes
|
||||
# first and depends on nothing else: a user stranded behind the failsafe must
|
||||
# not wait on the notification plugin, which may be slow or absent.
|
||||
if (( relock )) && ! relock_session; then
|
||||
echo "Omarchy shell restarted, but the session lock was not re-secured." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Core IPC answers before the notification plugin has registered its bus
|
||||
# name, so wait for it separately before one-time toasts are sent.
|
||||
if (( notifications_were_running )); then
|
||||
notifications_restored=0
|
||||
for (( attempt = 0; attempt < 20; attempt++ )); do
|
||||
if notifications_ready; then
|
||||
notifications_restored=1
|
||||
break
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
if (( notifications_restored == 0 )); then
|
||||
echo "Omarchy shell restarted, but its notification service did not become ready." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Invitation toasts (like Voxtype/fingerprint setup) die with the old
|
||||
# shell, and their notify-send waiters hang forever: the dying server
|
||||
# never emits NotificationClosed. A still-running omarchy-*-invitation
|
||||
# unit is therefore an unanswered invitation — re-run it so its toast
|
||||
# reappears on the new shell. Answered invitations have already exited
|
||||
# and been collected, so the glob no longer matches them.
|
||||
systemctl --user try-restart 'omarchy-*-invitation.service' 2>/dev/null || true
|
||||
exit 0
|
||||
Executable
+137
@@ -0,0 +1,137 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:hidden=true
|
||||
# omarchy:summary=Provide internal helpers for command-scoped sudo authentication
|
||||
|
||||
if [[ ${BASH_SOURCE[0]} == "$0" ]]; then
|
||||
echo "omarchy-security-functions is an internal function library." >&2
|
||||
exit 64
|
||||
fi
|
||||
|
||||
omarchy_security_require_privileged_bash_startup() {
|
||||
[[ $- == *p* ]] || return 1
|
||||
/usr/bin/env -i /usr/bin/bash -p -c '
|
||||
mapfile -d "" -t argv <"/proc/$1/cmdline" || exit 1
|
||||
executable=$(/usr/bin/readlink -e -- "/proc/$1/exe") || exit 1
|
||||
[[ $executable == "/usr/bin/bash" &&
|
||||
( ${argv[0]:-} == "/bin/bash" || ${argv[0]:-} == "/usr/bin/bash" ) &&
|
||||
${argv[1]:-} == "-p" ]]
|
||||
' omarchy-bash-startup "$$"
|
||||
}
|
||||
|
||||
omarchy_security_sanitize_bash_environment() {
|
||||
local script=$1
|
||||
shift
|
||||
local entry name environment_fd environment_pid
|
||||
local -a unsets=()
|
||||
|
||||
# Read the raw environment: privileged Bash ignores exported functions, but
|
||||
# leaves their records for ordinary child interpreters to import later.
|
||||
exec {environment_fd}< <(/usr/bin/env -0)
|
||||
environment_pid=$!
|
||||
while IFS= read -r -d '' entry <&"$environment_fd"; do
|
||||
name=${entry%%=*}
|
||||
case "$name" in
|
||||
BASH_ENV|ENV|SHELLOPTS|BASHOPTS|PS4|CDPATH|GLOBIGNORE|BASH_FUNC_*%%)
|
||||
unsets+=(-u "$name")
|
||||
;;
|
||||
esac
|
||||
done
|
||||
exec {environment_fd}<&-
|
||||
wait "$environment_pid" || return 1
|
||||
if (( ${#unsets[@]} > 0 )); then
|
||||
exec /usr/bin/env "${unsets[@]}" /usr/bin/bash -p -- "$script" "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
omarchy_security_require_source_root() {
|
||||
local command_source command_name=${1##*/}
|
||||
command_source=$(/usr/bin/readlink -e -- "$1") || return 1
|
||||
|
||||
# A runtime root selects the code used by this invocation. Accept the
|
||||
# canonical checkout containing the entrypoint or the package's bin links.
|
||||
if [[ ${OMARCHY_PATH:-} != /* || $(/usr/bin/realpath -e -- "$OMARCHY_PATH") != "$OMARCHY_PATH" ]] ||
|
||||
! { [[ $command_source == "$OMARCHY_PATH/bin/$command_name" ]] ||
|
||||
[[ $OMARCHY_PATH == "/usr/share/omarchy" && $command_source == "/usr/bin/$command_name" ]]; }; then
|
||||
echo "OMARCHY_PATH does not match this Omarchy command." >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
omarchy_security_sudo_supports_no_update() {
|
||||
local help
|
||||
help=$(LC_ALL=C /usr/bin/sudo -h 2>&1) || return 1
|
||||
/usr/bin/grep -Eq '^usage: sudo .*\[[^]]*N[^]]*\]' <<< "$help"
|
||||
}
|
||||
|
||||
omarchy_security_revoke_sudo_timestamp() {
|
||||
/usr/bin/sudo -k
|
||||
}
|
||||
|
||||
omarchy_security_exit_with_revoked_sudo() {
|
||||
local status=$1
|
||||
local message=${2:-Could not invalidate cached sudo authorization.}
|
||||
|
||||
trap - EXIT HUP INT TERM
|
||||
if ! omarchy_security_revoke_sudo_timestamp; then
|
||||
echo "$message" >&2
|
||||
(( status != 0 )) || status=1
|
||||
fi
|
||||
exit "$status"
|
||||
}
|
||||
|
||||
omarchy_security_install_signal_exit_traps() {
|
||||
trap 'exit 129' HUP
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
}
|
||||
|
||||
omarchy_security_install_sudo_cleanup_traps() {
|
||||
OMARCHY_SECURITY_SUDO_CLEANUP_MESSAGE=${1:-Could not invalidate cached sudo authorization.}
|
||||
trap omarchy_security_run_sudo_cleanup_trap EXIT
|
||||
omarchy_security_install_signal_exit_traps
|
||||
}
|
||||
|
||||
omarchy_security_enable_no_update_sudo() {
|
||||
local wrapper_dir="$OMARCHY_PATH/default/omarchy/sudo-no-update"
|
||||
if ! omarchy_security_sudo_supports_no_update; then
|
||||
echo "This sudo does not support --no-update; refusing mixed-trust work." >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ ! -f $wrapper_dir/sudo || ! -x $wrapper_dir/sudo ]]; then
|
||||
echo "The command-scoped sudo wrapper is missing." >&2
|
||||
return 1
|
||||
fi
|
||||
PATH="$wrapper_dir:$OMARCHY_PATH/bin:/usr/bin:/usr/sbin:/bin:/sbin"
|
||||
OMARCHY_SUDO_NO_UPDATE=1
|
||||
export PATH OMARCHY_SUDO_NO_UPDATE
|
||||
}
|
||||
|
||||
omarchy_security_run_sudo_cleanup_trap() {
|
||||
local status=$?
|
||||
|
||||
omarchy_security_exit_with_revoked_sudo "$status" \
|
||||
"${OMARCHY_SECURITY_SUDO_CLEANUP_MESSAGE:-Could not invalidate cached sudo authorization.}"
|
||||
}
|
||||
|
||||
omarchy_security_assert_root_directory() {
|
||||
local path=$1 expected_mode=$2 canonical owner actual_mode
|
||||
|
||||
[[ $path == /* && -d $path && ! -L $path ]] || return 1
|
||||
canonical=$(/usr/bin/realpath -e -- "$path") || return 1
|
||||
[[ $canonical == "$path" ]] || return 1
|
||||
read -r owner actual_mode < <(/usr/bin/stat -Lc '%u %a' -- "$path") || return 1
|
||||
[[ $owner == "0" && $actual_mode == "$expected_mode" ]]
|
||||
}
|
||||
|
||||
omarchy_security_prepare_private_root_directory() {
|
||||
local path=$1 parent=$2
|
||||
|
||||
omarchy_security_assert_root_directory "$parent" 755 || return 1
|
||||
if [[ -e $path || -L $path ]]; then
|
||||
omarchy_security_assert_root_directory "$path" 700
|
||||
else
|
||||
/usr/bin/install -d -o root -g root -m 0700 -- "$path" || return 1
|
||||
omarchy_security_assert_root_directory "$path" 700
|
||||
fi
|
||||
}
|
||||
+417
-39
@@ -1,70 +1,448 @@
|
||||
#!/bin/bash
|
||||
#!/bin/bash -p
|
||||
|
||||
# omarchy:summary=Toggle passwordless sudo for the current user.
|
||||
# omarchy:args=[MINUTES]
|
||||
# omarchy:requires-sudo=true
|
||||
|
||||
NOPASSWD_FILE="/etc/sudoers.d/99-omarchy-nopasswd-${USER}"
|
||||
TIMER_NAME="omarchy-nopasswd-expire-${USER}"
|
||||
|
||||
MINUTES=${1:-15}
|
||||
if [[ $1 && ! $1 =~ ^[0-9]+$ ]]; then
|
||||
echo "Usage: omarchy-sudo-passwordless [MINUTES]" >&2
|
||||
exit 1
|
||||
if [[ $- != *p* && ${BASH_SOURCE[0]} == "$0" ]]; then
|
||||
echo "Refusing an unsafe Bash startup for passwordless sudo." >&2
|
||||
exit 126
|
||||
fi
|
||||
|
||||
arm_expiry() {
|
||||
if sudo systemd-run --on-active=${MINUTES}m --timer-property=AccuracySec=1s --unit="$TIMER_NAME" \
|
||||
rm -f -- "$NOPASSWD_FILE"; then
|
||||
security_entrypoint=$(/usr/bin/readlink -e -- "${BASH_SOURCE[0]}") || exit 126
|
||||
source "${security_entrypoint%/*}/omarchy-security-functions" || exit 126
|
||||
|
||||
if [[ ${BASH_SOURCE[0]} == "$0" ]]; then
|
||||
omarchy_security_require_privileged_bash_startup || {
|
||||
echo "Refusing an unsafe Bash startup for passwordless sudo." >&2
|
||||
exit 126
|
||||
}
|
||||
omarchy_security_sanitize_bash_environment "$0" "$@" || exit 126
|
||||
fi
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
readonly DEFAULT_MINUTES=15
|
||||
readonly MAX_MINUTES=1440
|
||||
readonly LOCK_FILE=/run/lock/omarchy-sudo-passwordless.lock
|
||||
readonly BOOT_CLEANUP_FILE=/etc/tmpfiles.d/omarchy-nopasswd-sudo.conf
|
||||
readonly PACKAGE_HOOK=/usr/share/libalpm/hooks/05-omarchy-passwordless-revoke.hook
|
||||
readonly REMOVAL_BLOCKER=/run/omarchy-sudo-passwordless-package-removing
|
||||
readonly MIGRATION_MARKER=/var/lib/omarchy/migrations/1788163635
|
||||
readonly QUARANTINE_DIR=/var/lib/omarchy/sudoers-quarantine
|
||||
readonly INSTALLED_SELF=/usr/bin/omarchy-sudo-passwordless
|
||||
readonly STATUS_INACTIVE=3
|
||||
|
||||
usage() {
|
||||
echo "Usage: omarchy-sudo-passwordless [MINUTES]" >&2
|
||||
echo "MINUTES must be between 1 and $MAX_MINUTES." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
valid_minutes() {
|
||||
[[ $1 =~ ^0*[1-9][0-9]{0,3}$ ]] && ((10#$1 <= MAX_MINUTES))
|
||||
}
|
||||
|
||||
valid_uid() {
|
||||
[[ $1 =~ ^0*[1-9][0-9]{0,9}$ ]] && ((10#$1 <= 4294967294))
|
||||
}
|
||||
|
||||
valid_account_name() {
|
||||
[[ $1 =~ ^[a-z_][a-z0-9_-]{0,31}\$?$ ]] && (( ${#1} <= 32 ))
|
||||
}
|
||||
|
||||
resolve_account() {
|
||||
local uid="$1" entry
|
||||
valid_uid "$uid" || return 1
|
||||
entry=$(/usr/bin/getent passwd "$((10#$uid))") || return 1
|
||||
IFS=: read -r ACCOUNT_NAME _ ACCOUNT_UID _ _ _ _ <<<"$entry"
|
||||
[[ $ACCOUNT_UID == "$((10#$uid))" ]] || return 1
|
||||
# Sudoers has metacharacters, and it reads an upper-case word such as ALICE
|
||||
# as an alias reference rather than a user. Accounts use this portable
|
||||
# lower-case subset; refusing anything else is safer than attempting to
|
||||
# quote privileged policy syntax.
|
||||
valid_account_name "$ACCOUNT_NAME" || return 1
|
||||
ACCOUNT_UID=$((10#$uid))
|
||||
}
|
||||
|
||||
verify_sudo_caller() {
|
||||
local requested_uid="$1"
|
||||
((EUID == 0)) || return 1
|
||||
valid_uid "$requested_uid" || return 1
|
||||
[[ ${SUDO_UID:-} =~ ^[0-9]+$ ]] || return 1
|
||||
((10#$SUDO_UID == 10#$requested_uid)) || return 1
|
||||
resolve_account "$requested_uid"
|
||||
}
|
||||
|
||||
with_root_lock() {
|
||||
local fd rc=0
|
||||
# The boot cleanup cannot depend on STATE_DIR or RUNTIME_DIR being healthy:
|
||||
# those are exactly the kinds of partial-install state it must fail closed
|
||||
# through. /run/lock is established by the OS before sysinit services run.
|
||||
omarchy_security_assert_root_directory /run 755 || return 1
|
||||
[[ -d /run/lock && ! -L /run/lock ]] || return 1
|
||||
[[ $(/usr/bin/stat -Lc '%u' /run/lock) == 0 ]] || return 1
|
||||
! ((8#$(/usr/bin/stat -Lc '%a' /run/lock) & 022)) || return 1
|
||||
exec {fd}>"$LOCK_FILE" || return 1
|
||||
/usr/bin/chown root:root "$LOCK_FILE" || return 1
|
||||
/usr/bin/chmod 0600 "$LOCK_FILE" || return 1
|
||||
# Grant operations are short. A stalled holder must not hang a caller
|
||||
# indefinitely, least of all pacman's pre-transaction hook.
|
||||
/usr/bin/flock -x -w 60 "$fd" || return 1
|
||||
"$@" || rc=$?
|
||||
/usr/bin/flock -u "$fd" || rc=1
|
||||
exec {fd}>&-
|
||||
return "$rc"
|
||||
}
|
||||
|
||||
rule_file() {
|
||||
printf '/etc/sudoers.d/99-omarchy-nopasswd-%s' "$1"
|
||||
}
|
||||
|
||||
# The sudoers rule is the only grant record. A missing file is distinct from
|
||||
# an unreadable, unsafe, or administrator-modified file.
|
||||
read_grant() {
|
||||
local file contents
|
||||
file=$(rule_file "$1")
|
||||
[[ -e $file || -L $file ]] || return "$STATUS_INACTIVE"
|
||||
verify_root_path "$file" && [[ -f $file ]] || return 2
|
||||
contents=$(/usr/bin/cat -- "$file") || return 2
|
||||
[[ $contents =~ ^([a-z_][a-z0-9_-]*\$?)\ ALL=\(ALL\)\ NOTAFTER=([0-9]{14}Z)\ NOPASSWD:\ ALL$ ]] || return 2
|
||||
GRANT_NAME=${BASH_REMATCH[1]}
|
||||
GRANT_DEADLINE=${BASH_REMATCH[2]}
|
||||
valid_account_name "$GRANT_NAME" || return 2
|
||||
}
|
||||
|
||||
# Returns 0 for a rule this command generated, 1 for anything else under the
|
||||
# owned prefix (preserved as administrator policy), and 2 when unreadable.
|
||||
classify_generated_rule() {
|
||||
local file=$1 suffix contents name
|
||||
|
||||
[[ -f $file && ! -L $file ]] || return 1
|
||||
contents=$(/usr/bin/cat -- "$file") || return 2
|
||||
suffix=${file##*/99-omarchy-nopasswd-}
|
||||
|
||||
# The legacy command wrote the caller's unvalidated name into both the
|
||||
# filename and the rule. That exact relationship is its fingerprint, so an
|
||||
# account the current policy would reject still has its old grant removed.
|
||||
if [[ $contents == "$suffix ALL=(ALL) NOPASSWD: ALL" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "Failed to schedule passwordless sudo expiry. Revoking access now." >&2
|
||||
if ! sudo rm -f -- "$NOPASSWD_FILE"; then
|
||||
echo "CRITICAL: Could not remove $NOPASSWD_FILE. Remove it as root immediately." >&2
|
||||
[[ $suffix =~ ^[0-9]+$ ]] || return 1
|
||||
name=${contents%' ALL=(ALL) NOPASSWD: ALL'}
|
||||
if valid_account_name "$name" && [[ $contents == "$name ALL=(ALL) NOPASSWD: ALL" ]]; then
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
name=${contents%%' ALL=(ALL) NOTAFTER='*}
|
||||
valid_account_name "$name" && [[ $contents =~ ^[a-z_][a-z0-9_-]*\$?\ ALL=\(ALL\)\ NOTAFTER=[0-9]{14}Z\ NOPASSWD:\ ALL$ ]]
|
||||
}
|
||||
|
||||
cleanup_uid_locked() {
|
||||
local file
|
||||
file=$(rule_file "$1")
|
||||
[[ -e $file || -L $file ]] || return 0
|
||||
verify_root_path "$file" && classify_generated_rule "$file" || return 1
|
||||
/usr/bin/rm -f -- "$file" && [[ ! -e $file && ! -L $file ]]
|
||||
}
|
||||
|
||||
# The generated prefix is reserved: boot cleanup and the package hook already
|
||||
# remove everything in it, and the legacy writer could produce a rule whose
|
||||
# body differs from its filename. Nothing unrecognized may stay live there, but
|
||||
# its content is kept for the administrator instead of being deleted.
|
||||
quarantine_foreign_rule() {
|
||||
local file=$1 target
|
||||
if [[ ! -e /var/lib/omarchy && ! -L /var/lib/omarchy ]]; then
|
||||
/usr/bin/install -d -o root -g root -m 0755 -- /var/lib/omarchy || return 1
|
||||
fi
|
||||
omarchy_security_prepare_private_root_directory "$QUARANTINE_DIR" /var/lib/omarchy || return 1
|
||||
# A legacy filename can already be close to NAME_MAX, so the destination
|
||||
# name is fixed and the original name travels beside it.
|
||||
target=$(/usr/bin/mktemp -d "$QUARANTINE_DIR/XXXXXXXXXX") || return 1
|
||||
/usr/bin/printf '%s\n' "${file##*/}" >"$target/name" || return 1
|
||||
/usr/bin/mv -fT -- "$file" "$target/policy" && [[ ! -e $file && ! -L $file ]] || return 1
|
||||
echo "Moved unrecognized sudoers policy $file to $target/policy" >&2
|
||||
}
|
||||
|
||||
cleanup_all_locked() {
|
||||
local file classification failed=0
|
||||
verify_root_path /etc/sudoers.d || return 1
|
||||
for file in /etc/sudoers.d/99-omarchy-nopasswd-*; do
|
||||
[[ -e $file || -L $file ]] || continue
|
||||
if classify_generated_rule "$file"; then
|
||||
if ! /usr/bin/rm -f -- "$file" || [[ -e $file || -L $file ]]; then
|
||||
failed=1
|
||||
fi
|
||||
else
|
||||
classification=$?
|
||||
if (( classification != 1 )) || ! quarantine_foreign_rule "$file"; then
|
||||
failed=1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
return "$failed"
|
||||
}
|
||||
|
||||
verify_root_path() {
|
||||
local file=$1 owner mode canonical current
|
||||
[[ ( -f $file || -d $file ) && ! -L $file ]] || return 1
|
||||
canonical=$(/usr/bin/realpath -e -- "$file") || return 1
|
||||
[[ $canonical == "$file" ]] || return 1
|
||||
owner=$(/usr/bin/stat -Lc '%u' -- "$file") || return 1
|
||||
mode=$(/usr/bin/stat -Lc '%a' -- "$file") || return 1
|
||||
[[ $owner == 0 && $mode =~ ^[0-7]+$ ]] && ! ((8#$mode & 022)) || return 1
|
||||
|
||||
current=${file%/*}
|
||||
while :; do
|
||||
[[ -d $current && ! -L $current ]] || return 1
|
||||
canonical=$(/usr/bin/realpath -e -- "$current") || return 1
|
||||
[[ $canonical == "$current" ]] || return 1
|
||||
read -r owner mode < <(/usr/bin/stat -Lc '%u %a' -- "$current") || return 1
|
||||
[[ $owner == 0 && $mode =~ ^[0-7]+$ ]] && ! ((8#$mode & 022)) || return 1
|
||||
[[ $current == / ]] && break
|
||||
current=${current%/*}
|
||||
[[ -n $current ]] || current=/
|
||||
done
|
||||
}
|
||||
|
||||
verify_boot_cleanup() {
|
||||
local active_rules hook
|
||||
[[ ! -e $REMOVAL_BLOCKER && ! -L $REMOVAL_BLOCKER ]] || return 1
|
||||
verify_root_path "$BOOT_CLEANUP_FILE" || return 1
|
||||
active_rules=$(/usr/bin/awk '!/^[[:space:]]*(#|$)/ { print }' "$BOOT_CLEANUP_FILE") || return 1
|
||||
[[ $active_rules == 'r! /etc/sudoers.d/99-omarchy-nopasswd-*' ]] || return 1
|
||||
verify_root_path "$PACKAGE_HOOK" || return 1
|
||||
hook=$(/usr/bin/cat -- "$PACKAGE_HOOK") || return 1
|
||||
[[ $hook == '[Trigger]
|
||||
Operation = Upgrade
|
||||
Operation = Remove
|
||||
Type = Package
|
||||
Target = omarchy-settings
|
||||
Target = omarchy-settings-dev
|
||||
|
||||
[Action]
|
||||
Description = Revoking temporary Omarchy sudo grants before settings changes...
|
||||
When = PreTransaction
|
||||
Exec = /usr/bin/omarchy-sudo-passwordless __package-removing
|
||||
AbortOnFail' ]]
|
||||
}
|
||||
|
||||
package_removing_locked() {
|
||||
# ALPM must abort before removing the helper or boot cleanup if revocation
|
||||
# fails. The marker also blocks publication after this lock is released.
|
||||
(umask 077; : >"$REMOVAL_BLOCKER") || return 1
|
||||
/usr/bin/rm -f -- /etc/sudoers.d/99-omarchy-nopasswd-* || return 1
|
||||
cleanup_all_locked
|
||||
}
|
||||
|
||||
migration_complete() {
|
||||
[[ -f $MIGRATION_MARKER && ! -s $MIGRATION_MARKER ]] && verify_root_path "$MIGRATION_MARKER"
|
||||
}
|
||||
|
||||
migrate_locked() {
|
||||
local directory
|
||||
if migration_complete; then
|
||||
return 0
|
||||
fi
|
||||
[[ ! -e $MIGRATION_MARKER && ! -L $MIGRATION_MARKER ]] || return 1
|
||||
verify_root_path /var/lib || return 1
|
||||
for directory in /var/lib/omarchy /var/lib/omarchy/migrations; do
|
||||
if [[ ! -e $directory && ! -L $directory ]]; then
|
||||
/usr/bin/install -d -o root -g root -m 0755 -- "$directory" || return 1
|
||||
fi
|
||||
verify_root_path "$directory" || return 1
|
||||
done
|
||||
cleanup_all_locked || return 1
|
||||
# The empty marker is written only after cleanup succeeds, under the same
|
||||
# machine lock. Later accounts need no sudo and cannot revoke newer grants.
|
||||
/usr/bin/install -o root -g root -m 0644 /dev/null "$MIGRATION_MARKER"
|
||||
}
|
||||
|
||||
# Old callbacks only remove an expired current rule. Renewing a grant never
|
||||
# needs a second state file or a stored timer generation to identify it.
|
||||
expire_locked() {
|
||||
local status now
|
||||
if read_grant "$1"; then
|
||||
now=$(/usr/bin/date -u +%Y%m%d%H%M%SZ) || return 2
|
||||
[[ $now < $GRANT_DEADLINE ]] && return 0
|
||||
cleanup_uid_locked "$1"
|
||||
else
|
||||
status=$?
|
||||
if (( status == STATUS_INACTIVE )); then
|
||||
return 0
|
||||
else
|
||||
cleanup_uid_locked "$1"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
status_locked() {
|
||||
local status now
|
||||
resolve_account "$1" || return 2
|
||||
if read_grant "$1"; then
|
||||
[[ $GRANT_NAME == "$ACCOUNT_NAME" ]] || return 2
|
||||
now=$(/usr/bin/date -u +%Y%m%d%H%M%SZ) || return 2
|
||||
if [[ $now < $GRANT_DEADLINE ]]; then
|
||||
return 0
|
||||
fi
|
||||
cleanup_uid_locked "$1" || return 2
|
||||
return "$STATUS_INACTIVE"
|
||||
else
|
||||
status=$?
|
||||
return "$status"
|
||||
fi
|
||||
}
|
||||
|
||||
finish_enable() {
|
||||
local status=$?
|
||||
trap - EXIT HUP INT TERM
|
||||
if (( status != 0 )); then
|
||||
if cleanup_uid_locked "$uid"; then
|
||||
[[ -z $timer ]] || /usr/bin/systemctl stop "$timer.timer" "$timer.service" >/dev/null 2>&1 || true
|
||||
else
|
||||
echo "Could not revoke passwordless sudo; expiry remains armed. Administrator cleanup is required." >&2
|
||||
fi
|
||||
fi
|
||||
[[ -z $pending ]] || /usr/bin/rm -f -- "$pending"
|
||||
exit "$status"
|
||||
}
|
||||
|
||||
enable_locked() (
|
||||
local uid=$1 minutes=$2 now expires deadline token timer="" pending="" file status
|
||||
resolve_account "$uid" && valid_minutes "$minutes" || return 1
|
||||
verify_boot_cleanup && verify_root_path /etc/sudoers.d || return 1
|
||||
file=$(rule_file "$uid")
|
||||
if read_grant "$uid"; then
|
||||
[[ $GRANT_NAME == "$ACCOUNT_NAME" ]] || return 1
|
||||
else
|
||||
status=$?
|
||||
(( status == STATUS_INACTIVE )) || return 1
|
||||
fi
|
||||
trap finish_enable EXIT
|
||||
omarchy_security_install_signal_exit_traps
|
||||
now=$(/usr/bin/date +%s) || return 1
|
||||
expires=$((now + 10#$minutes * 60))
|
||||
deadline=$(/usr/bin/date -u -d "@$expires" +%Y%m%d%H%M%SZ) || return 1
|
||||
pending=$(/usr/bin/mktemp /etc/sudoers.d/.omarchy-nopasswd.XXXXXX) || return 1
|
||||
/usr/bin/printf '%s ALL=(ALL) NOTAFTER=%s NOPASSWD: ALL\n' "$ACCOUNT_NAME" "$deadline" >"$pending" || return 1
|
||||
/usr/bin/chown root:root "$pending" && /usr/bin/chmod 0440 "$pending" || return 1
|
||||
/usr/sbin/visudo -cf "$pending" >/dev/null || return 1
|
||||
token=$(/usr/bin/tr -d '-' </proc/sys/kernel/random/uuid) || return 1
|
||||
[[ $token =~ ^[0-9a-f]{32}$ ]] || return 1
|
||||
timer="omarchy-nopasswd-expire-$uid-$token"
|
||||
/usr/bin/systemd-run --quiet --collect --on-calendar="@$expires" \
|
||||
--timer-property=AccuracySec=1s --unit="$timer" \
|
||||
-- "$INSTALLED_SELF" __expire "$uid" || return 1
|
||||
/usr/bin/systemctl is-active --quiet "$timer.timer" || return 1
|
||||
# The temporary filename contains a dot, so sudo ignores it. Rename within
|
||||
# sudoers.d publishes the complete validated policy in one operation.
|
||||
/usr/bin/mv -fT -- "$pending" "$file" || return 1
|
||||
pending=""
|
||||
now=$(/usr/bin/date +%s) || return 1
|
||||
(( now < expires )) && verify_boot_cleanup && /usr/bin/systemctl is-active --quiet "$timer.timer"
|
||||
)
|
||||
|
||||
root_dispatch() {
|
||||
local action="$1"
|
||||
shift
|
||||
case "$action" in
|
||||
__status)
|
||||
(($# == 1)) && verify_sudo_caller "$1" || return 2
|
||||
with_root_lock status_locked "$1"
|
||||
;;
|
||||
__enable)
|
||||
(($# == 2)) && verify_sudo_caller "$1" && valid_minutes "$2" || return 1
|
||||
with_root_lock enable_locked "$1" "$2"
|
||||
;;
|
||||
__disable)
|
||||
(($# == 1)) && verify_sudo_caller "$1" || return 1
|
||||
with_root_lock cleanup_uid_locked "$1"
|
||||
;;
|
||||
__expire)
|
||||
(($# == 1 || $# == 2)) && ((EUID == 0)) && valid_uid "$1" || return 1
|
||||
[[ -z ${2:-} || $2 =~ ^omarchy-nopasswd-expire-${1}-[0-9a-f]{32}$ ]] || return 1
|
||||
with_root_lock expire_locked "$@"
|
||||
;;
|
||||
__migration-complete)
|
||||
(($# == 0)) && migration_complete
|
||||
;;
|
||||
__migrate)
|
||||
(($# == 0)) && ((EUID == 0)) || return 1
|
||||
with_root_lock migrate_locked
|
||||
;;
|
||||
__cleanup-all)
|
||||
(($# == 0)) && ((EUID == 0)) || return 1
|
||||
with_root_lock cleanup_all_locked
|
||||
;;
|
||||
__package-removing)
|
||||
(($# == 0)) && ((EUID == 0)) || return 1
|
||||
with_root_lock package_removing_locked
|
||||
;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
__status|__enable|__disable|__expire|__cleanup-all|__package-removing|__migrate|__migration-complete)
|
||||
action=$1
|
||||
shift
|
||||
root_dispatch "$action" "$@"
|
||||
exit
|
||||
;;
|
||||
esac
|
||||
|
||||
(($# <= 1)) || usage
|
||||
minutes=${1:-$DEFAULT_MINUTES}
|
||||
valid_minutes "$minutes" || usage
|
||||
uid=$(/usr/bin/id -u)
|
||||
valid_uid "$uid" || {
|
||||
echo "omarchy-sudo-passwordless: cannot grant passwordless sudo to this account" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
omarchy_security_sudo_supports_no_update || {
|
||||
echo "This sudo does not support --no-update; refusing the passwordless-sudo workflow." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
omarchy_security_install_sudo_cleanup_traps
|
||||
/usr/bin/sudo -k >/dev/null 2>&1 || {
|
||||
echo "Could not start from a cold sudo credential state." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
echo "Toggle passwordless sudo..."
|
||||
|
||||
# Safety: if the file exists but the timer doesn't (e.g. after reboot), clean up
|
||||
if sudo test -f "$NOPASSWD_FILE" && ! systemctl is-active "${TIMER_NAME}.timer" &>/dev/null; then
|
||||
sudo rm "$NOPASSWD_FILE"
|
||||
fi
|
||||
|
||||
# Check for the file directly — sudo -n can stay cached or be granted by other rules
|
||||
if sudo test -f "$NOPASSWD_FILE"; then
|
||||
if [[ $1 ]]; then
|
||||
sudo systemctl stop "${TIMER_NAME}.timer" 2>/dev/null
|
||||
arm_expiry || exit 1
|
||||
echo "Passwordless sudo timer updated. It will now automatically disable in ${MINUTES} minutes."
|
||||
else
|
||||
sudo rm "$NOPASSWD_FILE"
|
||||
sudo systemctl stop "${TIMER_NAME}.timer" 2>/dev/null
|
||||
if /usr/bin/sudo -N -- "$INSTALLED_SELF" __status "$uid"; then
|
||||
if (($# == 0)); then
|
||||
/usr/bin/sudo -N -- "$INSTALLED_SELF" __disable "$uid"
|
||||
echo "Passwordless sudo has been DISABLED. Sudo will require a password again."
|
||||
else
|
||||
/usr/bin/sudo -N -- "$INSTALLED_SELF" __enable "$uid" "$minutes"
|
||||
echo "Passwordless sudo expiry updated. It will automatically disable in ${minutes} minutes."
|
||||
fi
|
||||
else
|
||||
status=$?
|
||||
if (( status != STATUS_INACTIVE )); then
|
||||
echo "Could not safely inspect passwordless sudo; no grant will be enabled. Resolve the reported authorization or cleanup error first." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo ""
|
||||
echo "⚠️ WARNING: This will allow ANY process running as your user to"
|
||||
echo "execute ANY command as root WITHOUT a password for ${MINUTES} minutes."
|
||||
echo "execute ANY command as root WITHOUT a password for ${minutes} minutes."
|
||||
echo ""
|
||||
echo "This is useful for AI agents that need to run sudo commands,"
|
||||
echo "but it significantly weakens the security of your system."
|
||||
echo "Anyone or anything with access to your user account gets full root."
|
||||
echo ""
|
||||
echo "Passwordless sudo will automatically disable after ${MINUTES} minutes."
|
||||
echo "Passwordless sudo will automatically disable after ${minutes} minutes,"
|
||||
echo "including if the machine reboots before the deadline."
|
||||
echo "Run this command again to disable it early."
|
||||
echo ""
|
||||
|
||||
if gum confirm "Enable passwordless sudo for ${MINUTES} minutes? This is a significant security risk!"; then
|
||||
echo "${USER} ALL=(ALL) NOPASSWD: ALL" | sudo tee "$NOPASSWD_FILE" > /dev/null
|
||||
sudo chmod 440 "$NOPASSWD_FILE"
|
||||
arm_expiry || exit 1
|
||||
|
||||
if /usr/bin/gum confirm "Enable passwordless sudo for ${minutes} minutes? This is a significant security risk!"; then
|
||||
/usr/bin/sudo -N -- "$INSTALLED_SELF" __enable "$uid" "$minutes"
|
||||
echo ""
|
||||
echo "Passwordless sudo has been ENABLED. It will automatically disable in ${MINUTES} minutes."
|
||||
echo "A restart removes the passwordless sudo rule as well."
|
||||
echo "Passwordless sudo has been ENABLED. It will automatically disable in ${minutes} minutes."
|
||||
else
|
||||
echo "Aborted. No changes made."
|
||||
fi
|
||||
|
||||
+73
-15
@@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
#!/bin/bash -p
|
||||
|
||||
# omarchy:summary=Update Omarchy and system packages
|
||||
# omarchy:alias=omarchy up
|
||||
@@ -6,24 +6,58 @@
|
||||
# omarchy:examples=omarchy update | omarchy update -y
|
||||
# omarchy:requires-sudo=true
|
||||
|
||||
if [[ $- != *p* ]]; then
|
||||
echo "Refusing an unsafe Bash startup." >&2
|
||||
exit 126
|
||||
fi
|
||||
|
||||
security_entrypoint=$(/usr/bin/readlink -e -- "${BASH_SOURCE[0]}") || exit 126
|
||||
source "${security_entrypoint%/*}/omarchy-security-functions" || exit 126
|
||||
omarchy_security_require_privileged_bash_startup || exit 126
|
||||
set -e
|
||||
omarchy_security_sanitize_bash_environment "$0" "$@"
|
||||
omarchy_security_require_source_root "$0"
|
||||
# Logging and lock acquisition re-exec this command with a sanitized PATH.
|
||||
# Preserve the caller's path only for the later unprivileged hook/mise phases.
|
||||
user_path=${OMARCHY_UPDATE_USER_PATH:-$PATH}
|
||||
unset OMARCHY_UPDATE_USER_PATH
|
||||
# Traps first, so a signal or failure during the entry revocation still
|
||||
# exits through the cleanup path.
|
||||
omarchy_security_install_sudo_cleanup_traps
|
||||
omarchy_security_revoke_sudo_timestamp || exit 1
|
||||
omarchy_security_enable_no_update_sudo
|
||||
|
||||
update_stay_awake_stopped=0
|
||||
cleanup_update() {
|
||||
local status=$?
|
||||
trap - EXIT HUP INT TERM
|
||||
if ! omarchy_security_revoke_sudo_timestamp; then
|
||||
echo "Could not invalidate sudo before update cleanup." >&2
|
||||
omarchy_security_exit_with_revoked_sudo 1
|
||||
fi
|
||||
if (( update_stay_awake_stopped == 0 )); then
|
||||
omarchy-update-stay-awake stop || status=1
|
||||
fi
|
||||
omarchy_security_exit_with_revoked_sudo "$status"
|
||||
}
|
||||
|
||||
if [[ -z ${OMARCHY_UPDATE_LOGGED:-} ]]; then
|
||||
script_command=$(printf '%q ' "$0" "$@")
|
||||
exec env OMARCHY_UPDATE_LOGGED=1 script -qefc "$script_command" "/tmp/omarchy-update.log"
|
||||
exec env OMARCHY_UPDATE_LOGGED=1 OMARCHY_UPDATE_USER_PATH="$user_path" script -qefc "$script_command" "/tmp/omarchy-update.log"
|
||||
fi
|
||||
|
||||
if ! omarchy-update-lock held; then
|
||||
exec omarchy-update-lock run "$0" "$@"
|
||||
exec env OMARCHY_UPDATE_USER_PATH="$user_path" omarchy-update-lock run "$0" "$@"
|
||||
fi
|
||||
|
||||
trap 'echo ""; echo -e "\033[0;31mSomething went wrong during the update!\n\nPlease review the output above carefully, correct the error, and retry the update.\n\nIf you need assistance, get help from the community at https://omarchy.org/discord\033[0m"' ERR
|
||||
trap 'omarchy-update-stay-awake stop' EXIT
|
||||
trap cleanup_update EXIT
|
||||
omarchy_security_install_signal_exit_traps
|
||||
|
||||
omarchy-update-requires-free-space
|
||||
|
||||
# -y is a promise not to ask anything. Steps that would need an answer report
|
||||
# and move on instead of waiting on a prompt nobody is here to give.
|
||||
# -y suppresses Omarchy confirmation prompts; sudo authorization is still
|
||||
# required. Interactive review steps report and move on instead of waiting.
|
||||
[[ ${1:-} != "-y" ]] || export OMARCHY_UPDATE_UNATTENDED=1
|
||||
|
||||
if [[ ${1:-} == "-y" ]] || omarchy-update-confirm; then
|
||||
@@ -39,6 +73,7 @@ if [[ ${1:-} == "-y" ]] || omarchy-update-confirm; then
|
||||
|
||||
omarchy-update-stay-awake start
|
||||
|
||||
# Preserve the established development-checkout update ordering.
|
||||
omarchy-update-dev
|
||||
omarchy-update-keyring
|
||||
|
||||
@@ -46,20 +81,43 @@ if [[ ${1:-} == "-y" ]] || omarchy-update-confirm; then
|
||||
# them, so everything below waits on this finishing. An upgrade that stopped
|
||||
# takes the update with it rather than migrating against what is still on disk.
|
||||
omarchy-update-system-pkgs
|
||||
|
||||
# Historical migrations are strictly ordered and mix user hooks/downloaded
|
||||
# tooling with privileged repairs. The no-update sudo wrapper has covered the
|
||||
# whole update, so neither the package transaction nor a later repair can
|
||||
# publish a timestamp to a detached migration child.
|
||||
omarchy_security_revoke_sudo_timestamp
|
||||
omarchy-migrate
|
||||
omarchy-hook post-update
|
||||
omarchy-update-aur-pkgs
|
||||
omarchy-update-mise
|
||||
omarchy-update-orphan-pkgs
|
||||
|
||||
omarchy-update-analyze-logs
|
||||
omarchy-update-status
|
||||
|
||||
# Release update-owned inhibitors before offering a reboot. A confirmed
|
||||
# reboot can terminate this process before its EXIT trap gets a chance to
|
||||
# remove the persistent Stay Awake marker.
|
||||
omarchy-update-stay-awake stop
|
||||
trap - EXIT
|
||||
# Service restart helpers can need sudo. Run them before any user-controlled
|
||||
# update tooling; the reboot-only phase below performs no privileged work.
|
||||
omarchy-update-restart --services-only
|
||||
|
||||
omarchy-update-restart
|
||||
# AUR package installation must also use the no-update wrapper. Finish
|
||||
# update-owned system work before build code, hooks, or mise can run.
|
||||
omarchy_security_revoke_sudo_timestamp
|
||||
omarchy-update-aur-pkgs
|
||||
omarchy_security_revoke_sudo_timestamp
|
||||
|
||||
# Hooks and mise execute user-controlled code. Give each a cold credential
|
||||
# boundary and run mise last so it cannot wait for a legitimate hook sudo.
|
||||
# Only the unprivileged reboot prompt follows them.
|
||||
PATH="$OMARCHY_PATH/default/omarchy/sudo-no-update:$user_path" "$OMARCHY_PATH/bin/omarchy-hook" post-update
|
||||
omarchy_security_revoke_sudo_timestamp
|
||||
PATH="$OMARCHY_PATH/default/omarchy/sudo-no-update:$user_path" "$OMARCHY_PATH/bin/omarchy-update-mise"
|
||||
omarchy_security_revoke_sudo_timestamp
|
||||
|
||||
# The sleep inhibitor covers AUR builds, hooks and mise as well; releasing it
|
||||
# needs no privilege because the held command already dropped to this user.
|
||||
# Release it before offering a reboot: a confirmed reboot can terminate this
|
||||
# process before its EXIT trap gets a chance to remove the persistent Stay
|
||||
# Awake marker.
|
||||
omarchy-update-stay-awake stop
|
||||
update_stay_awake_stopped=1
|
||||
|
||||
"$OMARCHY_PATH/bin/omarchy-update-restart" --reboot-only
|
||||
fi
|
||||
@@ -2,10 +2,17 @@
|
||||
|
||||
# omarchy:summary=Update AUR packages if any are installed
|
||||
|
||||
sudo_options=()
|
||||
if [[ ${OMARCHY_SUDO_NO_UPDATE:-0} == "1" ]]; then
|
||||
sudo_wrapper="$OMARCHY_PATH/default/omarchy/sudo-no-update/sudo"
|
||||
[[ -x $sudo_wrapper ]] || exit 1
|
||||
sudo_options=(--sudo "$sudo_wrapper" --sudoloop=false)
|
||||
fi
|
||||
|
||||
if pacman -Qem >/dev/null; then
|
||||
if omarchy-pkg-aur-accessible; then
|
||||
echo -e "\e[32m\nUpdate AUR packages\e[0m"
|
||||
yay -Sua --noconfirm --cleanafter --ignore gcc14,gcc14-libs
|
||||
yay "${sudo_options[@]}" -Sua --noconfirm --cleanafter --ignore gcc14,gcc14-libs || exit 1
|
||||
echo
|
||||
else
|
||||
echo -e "\e[31m\nAUR is unavailable (so skipping updates)\e[0m"
|
||||
|
||||
+51
-36
@@ -1,51 +1,66 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Prompt for required reboot or service restarts after updates
|
||||
# omarchy:args=[--services-only|--reboot-only]
|
||||
|
||||
mode="${1:-all}"
|
||||
case "$mode" in
|
||||
all|--services-only|--reboot-only) ;;
|
||||
*) echo "Unknown restart phase: $mode" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
echo
|
||||
|
||||
confirm_reboot() {
|
||||
gum confirm "$1" && { omarchy-system-reboot; exit 0; }
|
||||
if [[ ${OMARCHY_UPDATE_UNATTENDED:-0} == "1" ]]; then
|
||||
echo "$1 Run omarchy-system-reboot when ready."
|
||||
elif gum confirm "$1"; then
|
||||
omarchy-system-reboot
|
||||
exit 0
|
||||
fi
|
||||
}
|
||||
|
||||
running_kernel=$(uname -r)
|
||||
kernel_updated=true
|
||||
if [[ $mode != "--services-only" ]]; then
|
||||
running_kernel=$(uname -r)
|
||||
kernel_updated=true
|
||||
|
||||
for kernel in /usr/lib/modules/*/vmlinuz; do
|
||||
if [[ -f $kernel ]] && pacman -Qo "$kernel" &>/dev/null; then
|
||||
installed_kernel=$(basename "$(dirname "$kernel")")
|
||||
for kernel in /usr/lib/modules/*/vmlinuz; do
|
||||
if [[ -f $kernel ]] && pacman -Qo "$kernel" &>/dev/null; then
|
||||
installed_kernel=$(basename "$(dirname "$kernel")")
|
||||
|
||||
if [[ $installed_kernel == $running_kernel ]]; then
|
||||
kernel_updated=false
|
||||
break
|
||||
if [[ $installed_kernel == $running_kernel ]]; then
|
||||
kernel_updated=false
|
||||
break
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
if [[ $kernel_updated == "true" ]]; then
|
||||
confirm_reboot "Linux kernel has been updated. Reboot?"
|
||||
elif [[ -f $HOME/.local/state/omarchy/reboot-required ]]; then
|
||||
confirm_reboot "Updates require reboot. Ready?"
|
||||
if [[ $kernel_updated == "true" ]]; then
|
||||
confirm_reboot "Linux kernel has been updated. Reboot?"
|
||||
elif [[ -f $HOME/.local/state/omarchy/reboot-required ]]; then
|
||||
confirm_reboot "Updates require reboot. Ready?"
|
||||
fi
|
||||
|
||||
running_hyprland=$(readlink /proc/$(pgrep -x Hyprland)/exe 2>/dev/null)
|
||||
if [[ $running_hyprland == *"(deleted)"* ]]; then
|
||||
confirm_reboot "Hyprland has been updated. Reboot?"
|
||||
fi
|
||||
fi
|
||||
|
||||
running_hyprland=$(readlink /proc/$(pgrep -x Hyprland)/exe 2>/dev/null)
|
||||
if [[ $running_hyprland == *"(deleted)"* ]]; then
|
||||
confirm_reboot "Hyprland has been updated. Reboot?"
|
||||
if [[ $mode != "--reboot-only" ]]; then
|
||||
for file in "$HOME"/.local/state/omarchy/restart-*-required; do
|
||||
if [[ -f $file ]]; then
|
||||
filename=$(basename "$file")
|
||||
service=$(echo "$filename" | sed 's/restart-\(.*\)-required/\1/')
|
||||
echo "Restarting $service"
|
||||
omarchy-state clear "$filename"
|
||||
omarchy-restart-"$service"
|
||||
fi
|
||||
done
|
||||
|
||||
# Updates routinely replace the shell's QML, and a stale process can lazy-load
|
||||
# new files into old code. A restart failure (locked session, ssh, TTY) only
|
||||
# prints its reason: the next update or login gets a fresh shell anyway.
|
||||
echo -e "\e[32m\nRestarting shell\e[0m"
|
||||
echo "All plugins have been reloaded"
|
||||
omarchy-restart-shell || true
|
||||
fi
|
||||
|
||||
for file in "$HOME"/.local/state/omarchy/restart-*-required; do
|
||||
if [[ -f $file ]]; then
|
||||
filename=$(basename "$file")
|
||||
service=$(echo "$filename" | sed 's/restart-\(.*\)-required/\1/')
|
||||
echo "Restarting $service"
|
||||
omarchy-state clear "$filename"
|
||||
omarchy-restart-"$service"
|
||||
fi
|
||||
done
|
||||
|
||||
# Updates routinely replace the shell's QML, and a stale process can lazy-load
|
||||
# new files into old code. A restart failure (locked session, ssh, TTY) only
|
||||
# prints its reason: the next update or login gets a fresh shell anyway.
|
||||
echo -e "\e[32m\nRestarting shell\e[0m"
|
||||
echo "All plugins have been reloaded"
|
||||
omarchy-restart-shell || true
|
||||
+520
-83
@@ -1,15 +1,231 @@
|
||||
#!/bin/bash
|
||||
#!/bin/bash -p
|
||||
|
||||
# omarchy:summary=Manage sleep and idle inhibition during an update
|
||||
# omarchy:args=<start|stop>
|
||||
# omarchy:hidden=true
|
||||
|
||||
if [[ $- != *p* ]]; then
|
||||
echo "Refusing an unsafe Bash startup." >&2
|
||||
exit 126
|
||||
fi
|
||||
|
||||
security_entrypoint=$(/usr/bin/readlink -e -- "${BASH_SOURCE[0]}") || exit 126
|
||||
source "${security_entrypoint%/*}/omarchy-security-functions" || exit 126
|
||||
omarchy_security_require_privileged_bash_startup || exit 126
|
||||
set -e
|
||||
omarchy_security_sanitize_bash_environment "$0" "$@"
|
||||
omarchy_security_require_source_root "$0"
|
||||
# Traps first, so a signal or failure during the entry revocation still
|
||||
# exits through the cleanup path.
|
||||
omarchy_security_install_sudo_cleanup_traps
|
||||
omarchy_security_revoke_sudo_timestamp || exit 1
|
||||
omarchy_security_enable_no_update_sudo
|
||||
|
||||
state_dir="${XDG_RUNTIME_DIR:-/tmp/omarchy-$UID}/omarchy-update-stay-awake"
|
||||
idle_owner_file="$state_dir/idle-owner"
|
||||
inhibit_pid_file="$state_dir/inhibit-pid"
|
||||
stay_awake_state="$HOME/.local/state/omarchy/indicators/stay-awake"
|
||||
caller_uid=""
|
||||
state_base=""
|
||||
state_dir=""
|
||||
idle_owner_file=""
|
||||
inhibit_pid_file=""
|
||||
launch_control_file=""
|
||||
launch_pending=0
|
||||
launch_token=""
|
||||
|
||||
fail_state_boundary() {
|
||||
echo "Refusing to use an unsafe Omarchy update inhibitor state path." >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
directory_is_private() {
|
||||
local directory="$1"
|
||||
local expected_owner="$2"
|
||||
local legacy="${3:-0}"
|
||||
local canonical=""
|
||||
local owner=""
|
||||
local mode=""
|
||||
|
||||
[[ -d $directory && ! -L $directory ]] || return 1
|
||||
canonical=$(readlink -e -- "$directory") || return 1
|
||||
[[ $canonical == "$directory" ]] || return 1
|
||||
read -r owner mode < <(stat -Lc '%u %a' -- "$directory") || return 1
|
||||
[[ $owner == "$expected_owner" ]] || return 1
|
||||
[[ $mode == "700" ]] || (( legacy == 1 && (8#$mode & 022) == 0 ))
|
||||
}
|
||||
|
||||
root_owned_parent_chain() {
|
||||
local directory="$1"
|
||||
local parent owner mode type canonical
|
||||
|
||||
parent=$(/usr/bin/dirname -- "$directory") || return 1
|
||||
while :; do
|
||||
[[ -d $parent && ! -L $parent ]] || return 1
|
||||
canonical=$(/usr/bin/readlink -e -- "$parent") || return 1
|
||||
[[ $canonical == "$parent" ]] || return 1
|
||||
read -r owner mode type < <(/usr/bin/stat -Lc '%u %a %F' -- "$parent") || return 1
|
||||
[[ $owner == 0 && $type == "directory" ]] || return 1
|
||||
! ((8#$mode & 022)) || return 1
|
||||
[[ $parent == / ]] && break
|
||||
parent=$(/usr/bin/dirname -- "$parent") || return 1
|
||||
done
|
||||
}
|
||||
|
||||
runtime_directory_is_private() {
|
||||
local directory="$1" expected_owner="$2"
|
||||
directory_is_private "$directory" "$expected_owner" &&
|
||||
root_owned_parent_chain "$directory"
|
||||
}
|
||||
|
||||
ensure_private_directory() {
|
||||
local directory="$1"
|
||||
|
||||
if [[ ! -e $directory && ! -L $directory ]]; then
|
||||
mkdir -m 700 -- "$directory" 2>/dev/null || true
|
||||
fi
|
||||
# Older helpers used mkdir -p with the caller's umask. Tighten only a
|
||||
# canonical, caller-owned directory that other accounts cannot write.
|
||||
directory_is_private "$directory" "$caller_uid" 1 || return 1
|
||||
chmod 700 -- "$directory" || return 1
|
||||
directory_is_private "$directory" "$caller_uid"
|
||||
}
|
||||
|
||||
initialize_state_boundary() {
|
||||
local canonical_tmp=""
|
||||
local tmp_owner=""
|
||||
local tmp_mode=""
|
||||
|
||||
caller_uid="$EUID"
|
||||
[[ $caller_uid =~ ^[0-9]+$ ]] || return 1
|
||||
|
||||
if [[ -n ${XDG_RUNTIME_DIR:-} ]]; then
|
||||
runtime_directory_is_private "$XDG_RUNTIME_DIR" "$caller_uid" || fail_state_boundary
|
||||
state_base="$XDG_RUNTIME_DIR"
|
||||
else
|
||||
[[ -d /tmp && ! -L /tmp ]] || fail_state_boundary
|
||||
canonical_tmp=$(readlink -e -- /tmp) || fail_state_boundary
|
||||
read -r tmp_owner tmp_mode < <(stat -Lc '%u %a' -- /tmp) || fail_state_boundary
|
||||
[[ $canonical_tmp == "/tmp" && $tmp_owner == "0" && $tmp_mode == "1777" ]] || fail_state_boundary
|
||||
|
||||
state_base="/tmp/omarchy-$caller_uid"
|
||||
ensure_private_directory "$state_base" || fail_state_boundary
|
||||
fi
|
||||
|
||||
state_dir="$state_base/omarchy-update-stay-awake"
|
||||
idle_owner_file="$state_dir/idle-owner"
|
||||
inhibit_pid_file="$state_dir/inhibit-pid"
|
||||
launch_control_file="$state_dir/launch-control"
|
||||
}
|
||||
|
||||
state_file_is_private() {
|
||||
local state_file="$1"
|
||||
local legacy="${2:-0}"
|
||||
local owner=""
|
||||
local mode=""
|
||||
local links=""
|
||||
|
||||
[[ -f $state_file && ! -L $state_file ]] || return 1
|
||||
read -r owner mode links < <(stat -Lc '%u %a %h' -- "$state_file") || return 1
|
||||
[[ $owner == "$caller_uid" && $links == "1" ]] || return 1
|
||||
[[ $mode == "600" ]] || (( legacy == 1 && (8#$mode & 022) == 0 ))
|
||||
}
|
||||
|
||||
read_state_record() {
|
||||
local state_file="$1"
|
||||
local legacy="${2:-0}"
|
||||
local records=()
|
||||
local file_size=""
|
||||
local LC_ALL=C
|
||||
|
||||
state_file_is_private "$state_file" "$legacy" || return 1
|
||||
mapfile -t records <"$state_file" || return 1
|
||||
(( ${#records[@]} == 1 )) || return 1
|
||||
file_size=$(stat -Lc '%s' -- "$state_file") || return 1
|
||||
(( file_size == ${#records[0]} + 1 )) || return 1
|
||||
state_file_is_private "$state_file" "$legacy" || return 1
|
||||
printf '%s\n' "${records[0]}"
|
||||
}
|
||||
|
||||
atomic_write_state() {
|
||||
local state_file="$1"
|
||||
local record="$2"
|
||||
local temporary=""
|
||||
|
||||
[[ $record != *$'\n'* ]] || return 1
|
||||
temporary=$(mktemp "$state_dir/.${state_file##*/}.XXXXXXXX") || return 1
|
||||
chmod 600 "$temporary" || {
|
||||
rm -f -- "$temporary"
|
||||
return 1
|
||||
}
|
||||
if ! printf '%s\n' "$record" >"$temporary" || ! state_file_is_private "$temporary"; then
|
||||
rm -f -- "$temporary"
|
||||
return 1
|
||||
fi
|
||||
if [[ -e $state_file || -L $state_file ]]; then
|
||||
state_file_is_private "$state_file" || {
|
||||
rm -f -- "$temporary"
|
||||
return 1
|
||||
}
|
||||
fi
|
||||
mv -fT -- "$temporary" "$state_file" || {
|
||||
rm -f -- "$temporary"
|
||||
return 1
|
||||
}
|
||||
state_file_is_private "$state_file"
|
||||
}
|
||||
|
||||
rollback_pending_launch() {
|
||||
local control_fd=""
|
||||
local inhibit_record=""
|
||||
local inhibit_pid=""
|
||||
local recorded_start_time=""
|
||||
local recorded_owner=""
|
||||
local token=""
|
||||
|
||||
(( launch_pending == 1 )) || return 0
|
||||
|
||||
if [[ -e $launch_control_file || -L $launch_control_file ]]; then
|
||||
state_file_is_private "$launch_control_file" || return 1
|
||||
exec {control_fd}<>"$launch_control_file" || return 1
|
||||
/usr/bin/flock -x "$control_fd" || {
|
||||
exec {control_fd}>&-
|
||||
return 1
|
||||
}
|
||||
: >"/proc/self/fd/$control_fd"
|
||||
printf 'cancelled %s\n' "$launch_token" >&"$control_fd"
|
||||
rm -f -- "$launch_control_file"
|
||||
/usr/bin/flock -u "$control_fd"
|
||||
exec {control_fd}>&-
|
||||
fi
|
||||
|
||||
inhibit_record=$(read_state_record "$inhibit_pid_file" 2>/dev/null || true)
|
||||
if [[ $inhibit_record =~ ^1\ ([1-9][0-9]{0,18})\ ([1-9][0-9]{0,18})\ ([0-9]{1,10})\ ($launch_token)$ ]]; then
|
||||
inhibit_pid="${BASH_REMATCH[1]}"
|
||||
recorded_start_time="${BASH_REMATCH[2]}"
|
||||
recorded_owner="${BASH_REMATCH[3]}"
|
||||
token="${BASH_REMATCH[4]}"
|
||||
if process_matches "$inhibit_pid" "$recorded_start_time" "$recorded_owner" "$token"; then
|
||||
discard_launched_inhibitor "$inhibit_pid" "$recorded_start_time" "$recorded_owner" "$token"
|
||||
fi
|
||||
[[ $(read_state_record "$inhibit_pid_file" 2>/dev/null || true) != "$inhibit_record" ]] ||
|
||||
rm -f -- "$inhibit_pid_file"
|
||||
fi
|
||||
|
||||
# A signal may interrupt either parent-side atomic write before its rename.
|
||||
rm -f -- "$state_dir"/.launch-control.* "$state_dir"/.idle-owner.*
|
||||
stop_locked || return 1
|
||||
launch_pending=0
|
||||
}
|
||||
|
||||
cleanup_pending_launch() {
|
||||
local status=$?
|
||||
|
||||
trap - EXIT HUP INT TERM
|
||||
if ! rollback_pending_launch; then
|
||||
echo "Failed to roll back the pending Omarchy update sleep inhibitor." >&2
|
||||
(( status != 0 )) || status=1
|
||||
fi
|
||||
omarchy_security_exit_with_revoked_sudo "$status" \
|
||||
"Failed to invalidate sudo credentials after the update sleep inhibitor."
|
||||
}
|
||||
|
||||
process_start_time() {
|
||||
local process_pid="$1"
|
||||
@@ -21,9 +237,67 @@ process_start_time() {
|
||||
process_stat="${process_stat##*) }"
|
||||
read -r -a stat_fields <<<"$process_stat"
|
||||
(( ${#stat_fields[@]} > 19 )) || return 1
|
||||
[[ ${stat_fields[19]} =~ ^[0-9]+$ ]] || return 1
|
||||
printf '%s\n' "${stat_fields[19]}"
|
||||
}
|
||||
|
||||
process_owner() {
|
||||
local process_pid="$1"
|
||||
local token="$2"
|
||||
local owner=""
|
||||
|
||||
if [[ -n $token ]]; then
|
||||
owner=$(stat -Lc '%u' -- "/proc/$process_pid") || return 1
|
||||
else
|
||||
# Legacy state can name sudo: its real UID remains the invoking account,
|
||||
# even when /proc ownership reflects its effective root credentials.
|
||||
owner=$(awk '/^Uid:/ { print $2; exit }' "/proc/$process_pid/status") || return 1
|
||||
fi
|
||||
[[ $owner =~ ^[0-9]+$ ]] || return 1
|
||||
printf '%s\n' "$owner"
|
||||
}
|
||||
|
||||
process_has_token() {
|
||||
local process_pid="$1"
|
||||
local token="$2"
|
||||
local argument=""
|
||||
local expected="--why=Omarchy update in progress [$token]"
|
||||
|
||||
[[ -r /proc/$process_pid/cmdline ]] || return 1
|
||||
while IFS= read -r -d '' argument; do
|
||||
[[ $argument == "$expected" ]] && return 0
|
||||
done <"/proc/$process_pid/cmdline"
|
||||
return 1
|
||||
}
|
||||
|
||||
process_identity() {
|
||||
local process_pid="$1"
|
||||
local token="$2"
|
||||
local start_before=""
|
||||
local start_after=""
|
||||
local owner_before=""
|
||||
local owner_after=""
|
||||
|
||||
start_before=$(process_start_time "$process_pid") || return 1
|
||||
owner_before=$(process_owner "$process_pid" "$token") || return 1
|
||||
[[ -z $token ]] || process_has_token "$process_pid" "$token" || return 1
|
||||
start_after=$(process_start_time "$process_pid") || return 1
|
||||
owner_after=$(process_owner "$process_pid" "$token") || return 1
|
||||
[[ $start_before == "$start_after" && $owner_before == "$owner_after" ]] || return 1
|
||||
printf '%s %s\n' "$start_before" "$owner_before"
|
||||
}
|
||||
|
||||
process_matches() {
|
||||
local process_pid="$1"
|
||||
local expected_start="$2"
|
||||
local expected_owner="$3"
|
||||
local token="$4"
|
||||
local identity=""
|
||||
|
||||
identity=$(process_identity "$process_pid" "$token" 2>/dev/null) || return 1
|
||||
[[ $identity == "$expected_start $expected_owner" ]]
|
||||
}
|
||||
|
||||
process_state() {
|
||||
local process_pid="$1"
|
||||
local process_stat=""
|
||||
@@ -34,113 +308,276 @@ process_state() {
|
||||
printf '%s\n' "${process_stat%% *}"
|
||||
}
|
||||
|
||||
stop() {
|
||||
local inhibit_pid=""
|
||||
local recorded_start_time=""
|
||||
local current_start_time=""
|
||||
local idle_owner=""
|
||||
local current_idle_owner=""
|
||||
discard_launched_inhibitor() {
|
||||
local inhibit_pid="$1"
|
||||
local recorded_start_time="$2"
|
||||
local recorded_owner="$3"
|
||||
local token="$4"
|
||||
|
||||
if [[ -s $idle_owner_file ]]; then
|
||||
idle_owner=$(<"$idle_owner_file")
|
||||
if [[ -f $stay_awake_state ]]; then
|
||||
current_idle_owner=$(<"$stay_awake_state")
|
||||
fi
|
||||
if [[ -n $idle_owner && $current_idle_owner == "$idle_owner" ]]; then
|
||||
omarchy-toggle-idle allow-idle >/dev/null 2>&1 || true
|
||||
fi
|
||||
rm -f "$idle_owner_file"
|
||||
signal_inhibitor "$inhibit_pid" "$recorded_start_time" "$recorded_owner" "$token" TERM || true
|
||||
for (( attempt = 0; attempt < 25; attempt++ )); do
|
||||
process_matches "$inhibit_pid" "$recorded_start_time" "$recorded_owner" "$token" || break
|
||||
[[ $(process_state "$inhibit_pid" 2>/dev/null || true) == "Z" ]] && break
|
||||
sleep 0.02
|
||||
done
|
||||
if process_matches "$inhibit_pid" "$recorded_start_time" "$recorded_owner" "$token" &&
|
||||
[[ $(process_state "$inhibit_pid" 2>/dev/null || true) != "Z" ]]; then
|
||||
signal_inhibitor "$inhibit_pid" "$recorded_start_time" "$recorded_owner" "$token" KILL || true
|
||||
fi
|
||||
|
||||
if [[ -s $inhibit_pid_file ]]; then
|
||||
read -r inhibit_pid recorded_start_time <"$inhibit_pid_file" || true
|
||||
if [[ $inhibit_pid =~ ^[0-9]+$ ]]; then
|
||||
current_start_time=$(process_start_time "$inhibit_pid" 2>/dev/null || true)
|
||||
if [[ ! -e /proc/$inhibit_pid ]] || [[ $(process_state "$inhibit_pid" 2>/dev/null || true) == "Z" ]]; then
|
||||
wait "$inhibit_pid" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
signal_inhibitor() {
|
||||
local inhibit_pid="$1"
|
||||
local recorded_start_time="$2"
|
||||
local recorded_owner="$3"
|
||||
local token="$4"
|
||||
local signal="$5"
|
||||
|
||||
[[ $recorded_owner == "$caller_uid" ]] || return 1
|
||||
[[ $signal == "TERM" || $signal == "KILL" ]] || return 1
|
||||
process_matches "$inhibit_pid" "$recorded_start_time" "$recorded_owner" "$token" || return 1
|
||||
builtin kill -s "$signal" -- "$inhibit_pid" 2>/dev/null
|
||||
}
|
||||
|
||||
terminate_inhibitor() {
|
||||
local inhibit_pid="$1"
|
||||
local recorded_start_time="$2"
|
||||
local recorded_owner="$3"
|
||||
local token="$4"
|
||||
|
||||
signal_inhibitor "$inhibit_pid" "$recorded_start_time" "$recorded_owner" "$token" TERM || true
|
||||
|
||||
for (( attempt = 0; attempt < 50; attempt++ )); do
|
||||
process_matches "$inhibit_pid" "$recorded_start_time" "$recorded_owner" "$token" || return 0
|
||||
[[ $(process_state "$inhibit_pid" 2>/dev/null || true) != "Z" ]] || return 0
|
||||
sleep 0.02
|
||||
done
|
||||
|
||||
process_matches "$inhibit_pid" "$recorded_start_time" "$recorded_owner" "$token" || return 0
|
||||
[[ $(process_state "$inhibit_pid" 2>/dev/null || true) == "Z" ]] && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
stop_locked() {
|
||||
local inhibit_record=""
|
||||
local inhibit_pid=""
|
||||
local recorded_start_time=""
|
||||
local recorded_owner=""
|
||||
local token=""
|
||||
local idle_owner=""
|
||||
local current_idle_owner=""
|
||||
local current_start=""
|
||||
local failed=0
|
||||
local remove_inhibit_state=1
|
||||
|
||||
if [[ ! -e $state_dir && ! -L $state_dir ]]; then
|
||||
return 0
|
||||
fi
|
||||
if ! ensure_private_directory "$state_dir"; then
|
||||
fail_state_boundary
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ -e $idle_owner_file || -L $idle_owner_file ]]; then
|
||||
idle_owner=$(read_state_record "$idle_owner_file" 1 2>/dev/null || true)
|
||||
if [[ $idle_owner =~ ^[0-9]+:[0-9]+:[0-9]+$ ]]; then
|
||||
if [[ -f $stay_awake_state ]]; then
|
||||
current_idle_owner=$(<"$stay_awake_state")
|
||||
fi
|
||||
if [[ $current_idle_owner == "$idle_owner" ]]; then
|
||||
omarchy-toggle-idle allow-idle >/dev/null 2>&1 || true
|
||||
fi
|
||||
else
|
||||
echo "Ignoring unsafe Omarchy update idle ownership state." >&2
|
||||
failed=1
|
||||
fi
|
||||
if [[ -n $recorded_start_time && $current_start_time == "$recorded_start_time" ]]; then
|
||||
kill "$inhibit_pid" >/dev/null 2>&1 || true
|
||||
rm -f -- "$idle_owner_file"
|
||||
fi
|
||||
|
||||
for (( attempt = 0; attempt < 50; attempt++ )); do
|
||||
current_start_time=$(process_start_time "$inhibit_pid" 2>/dev/null || true)
|
||||
[[ $current_start_time == "$recorded_start_time" ]] || break
|
||||
[[ $(process_state "$inhibit_pid" 2>/dev/null || true) != "Z" ]] || break
|
||||
sleep 0.02
|
||||
done
|
||||
|
||||
current_start_time=$(process_start_time "$inhibit_pid" 2>/dev/null || true)
|
||||
if [[ $current_start_time == "$recorded_start_time" ]] &&
|
||||
[[ $(process_state "$inhibit_pid" 2>/dev/null || true) != "Z" ]]; then
|
||||
echo "Failed to stop the Omarchy update sleep inhibitor." >&2
|
||||
return 1
|
||||
if [[ -e $inhibit_pid_file || -L $inhibit_pid_file ]]; then
|
||||
inhibit_record=$(read_state_record "$inhibit_pid_file" 1 2>/dev/null || true)
|
||||
if state_file_is_private "$inhibit_pid_file" &&
|
||||
[[ $inhibit_record =~ ^1\ ([1-9][0-9]{0,18})\ ([1-9][0-9]{0,18})\ ([0-9]{1,10})\ ([0-9a-f]{32})$ ]]; then
|
||||
inhibit_pid="${BASH_REMATCH[1]}"
|
||||
recorded_start_time="${BASH_REMATCH[2]}"
|
||||
recorded_owner="${BASH_REMATCH[3]}"
|
||||
token="${BASH_REMATCH[4]}"
|
||||
elif [[ $inhibit_record =~ ^([1-9][0-9]{0,18})\ ([1-9][0-9]{0,18})$ ]]; then
|
||||
# The first update starts the old helper and installs this one before
|
||||
# cleanup. Its protected same-account record predates launch tokens.
|
||||
inhibit_pid="${BASH_REMATCH[1]}"
|
||||
recorded_start_time="${BASH_REMATCH[2]}"
|
||||
recorded_owner="$caller_uid"
|
||||
if [[ -e /proc/$inhibit_pid && $(process_state "$inhibit_pid" 2>/dev/null || true) != "Z" ]] &&
|
||||
! process_matches "$inhibit_pid" "$recorded_start_time" "$recorded_owner" ""; then
|
||||
current_start=$(process_start_time "$inhibit_pid" 2>/dev/null || true)
|
||||
if [[ -z $current_start || $current_start == "$recorded_start_time" ]]; then
|
||||
echo "Cannot verify permission to stop the legacy update inhibitor; retained its state for recovery." >&2
|
||||
failed=1
|
||||
remove_inhibit_state=0
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo "Ignoring unsafe Omarchy update sleep inhibitor state." >&2
|
||||
failed=1
|
||||
fi
|
||||
if [[ -n $inhibit_pid ]] && (( remove_inhibit_state == 1 )); then
|
||||
if process_matches "$inhibit_pid" "$recorded_start_time" "$recorded_owner" "$token"; then
|
||||
if ! terminate_inhibitor "$inhibit_pid" "$recorded_start_time" "$recorded_owner" "$token"; then
|
||||
echo "Failed to stop the Omarchy update sleep inhibitor." >&2
|
||||
failed=1
|
||||
remove_inhibit_state=0
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
rm -f "$inhibit_pid_file"
|
||||
(( remove_inhibit_state == 0 )) || rm -f -- "$inhibit_pid_file"
|
||||
fi
|
||||
|
||||
rmdir "$state_dir" 2>/dev/null || true
|
||||
(( failed == 0 ))
|
||||
}
|
||||
|
||||
start() {
|
||||
start_locked() {
|
||||
local idle_owner="$$:$RANDOM:$RANDOM"
|
||||
local token=""
|
||||
local launcher_pid=""
|
||||
local inhibit_record=""
|
||||
local inhibit_pid=""
|
||||
local inhibit_start_time=""
|
||||
local inhibit_runner=()
|
||||
local idle_owner="$$:$RANDOM:$RANDOM"
|
||||
local inhibit_owner=""
|
||||
local readiness_attempts=0
|
||||
|
||||
stop
|
||||
mkdir -p "$state_dir"
|
||||
stop_locked || return 1
|
||||
ensure_private_directory "$state_dir" || fail_state_boundary
|
||||
|
||||
if omarchy-cmd-present systemd-inhibit; then
|
||||
if (( EUID != 0 )); then
|
||||
if [[ -t 0 ]]; then
|
||||
sudo -v
|
||||
inhibit_runner=(sudo)
|
||||
else
|
||||
inhibit_runner=(pkexec)
|
||||
fi
|
||||
fi
|
||||
token=$(LC_ALL=C /usr/bin/od -An -N16 -tx1 /dev/urandom | /usr/bin/tr -d ' \n')
|
||||
[[ $token =~ ^[0-9a-f]{32}$ ]] || return 1
|
||||
launch_token="$token"
|
||||
launch_pending=1
|
||||
trap cleanup_pending_launch EXIT
|
||||
atomic_write_state "$launch_control_file" "active $token" || return 1
|
||||
|
||||
if [[ -n ${OMARCHY_UPDATE_LOCK_FD:-} ]]; then
|
||||
"${inhibit_runner[@]}" systemd-inhibit \
|
||||
--what=sleep:idle \
|
||||
--who=omarchy-update \
|
||||
--why="Omarchy update in progress" \
|
||||
--mode=block \
|
||||
sleep infinity >/dev/null 2>&1 {OMARCHY_UPDATE_LOCK_FD}>&- &
|
||||
else
|
||||
"${inhibit_runner[@]}" systemd-inhibit \
|
||||
--what=sleep:idle \
|
||||
--who=omarchy-update \
|
||||
--why="Omarchy update in progress" \
|
||||
--mode=block \
|
||||
sleep infinity >/dev/null 2>&1 &
|
||||
fi
|
||||
inhibit_pid=$!
|
||||
inhibit_start_time=$(process_start_time "$inhibit_pid" 2>/dev/null || true)
|
||||
if [[ -n $inhibit_start_time ]]; then
|
||||
printf '%s %s\n' "$inhibit_pid" "$inhibit_start_time" >"$inhibit_pid_file"
|
||||
# The child is identifiable before it publishes state, including before exec.
|
||||
# exec -a retains that identity without keeping an extra shell alive.
|
||||
local hold_command=(
|
||||
/usr/bin/systemd-inhibit --what=sleep:idle --who=omarchy-update
|
||||
--why="Omarchy update in progress [$token]" --mode=block
|
||||
/usr/bin/setpriv --reuid "$caller_uid" --regid "$(/usr/bin/id -g)" --clear-groups
|
||||
/usr/bin/bash -p -c '
|
||||
set -e
|
||||
umask 077
|
||||
state_dir=$1
|
||||
token=$2
|
||||
owner=$3
|
||||
control=$4
|
||||
expected="--why=Omarchy update in progress [$token]"
|
||||
temporary=""
|
||||
cleanup() { [[ -z $temporary ]] || /usr/bin/rm -f -- "$temporary"; }
|
||||
trap cleanup EXIT
|
||||
read -r process_stat <"/proc/$$/stat"
|
||||
process_stat=${process_stat##*) }
|
||||
read -r -a fields <<<"$process_stat"
|
||||
temporary=$(/usr/bin/mktemp "$state_dir/.inhibit-pid.XXXXXXXX")
|
||||
/usr/bin/chmod 600 "$temporary"
|
||||
printf "1 %s %s %s %s\n" "$$" "${fields[19]}" "$owner" "$token" >"$temporary"
|
||||
exec {control_fd}<"$control"
|
||||
/usr/bin/flock -x "$control_fd"
|
||||
IFS= read -r control_record <&"$control_fd"
|
||||
[[ $control_record == "active $token" ]]
|
||||
/usr/bin/mv -fT -- "$temporary" "$state_dir/inhibit-pid"
|
||||
temporary=""
|
||||
/usr/bin/flock -u "$control_fd"
|
||||
exec {control_fd}>&-
|
||||
trap - EXIT
|
||||
exec -a "$expected" /usr/bin/sleep infinity
|
||||
' "--why=Omarchy update in progress [$token]" "$state_dir" "$token" "$caller_uid" "$launch_control_file"
|
||||
)
|
||||
|
||||
if (( EUID == 0 )); then
|
||||
(
|
||||
[[ -z ${OMARCHY_UPDATE_LOCK_FD:-} ]] || exec {OMARCHY_UPDATE_LOCK_FD}>&-
|
||||
exec {state_lock_fd}>&-
|
||||
exec "${hold_command[@]}"
|
||||
) &
|
||||
launcher_pid=$!
|
||||
elif [[ -t 0 ]]; then
|
||||
if ! (
|
||||
[[ -z ${OMARCHY_UPDATE_LOCK_FD:-} ]] || exec {OMARCHY_UPDATE_LOCK_FD}>&-
|
||||
exec {state_lock_fd}>&-
|
||||
exec /usr/bin/sudo -N -b -- "${hold_command[@]}"
|
||||
); then
|
||||
return 1
|
||||
fi
|
||||
else
|
||||
(
|
||||
[[ -z ${OMARCHY_UPDATE_LOCK_FD:-} ]] || exec {OMARCHY_UPDATE_LOCK_FD}>&-
|
||||
exec {state_lock_fd}>&-
|
||||
exec /usr/bin/pkexec "${hold_command[@]}"
|
||||
) &
|
||||
launcher_pid=$!
|
||||
fi
|
||||
|
||||
while :; do
|
||||
inhibit_record=$(read_state_record "$inhibit_pid_file" 2>/dev/null || true)
|
||||
if [[ $inhibit_record =~ ^1\ ([1-9][0-9]{0,18})\ ([1-9][0-9]{0,18})\ ([0-9]{1,10})\ ($token)$ ]]; then
|
||||
inhibit_pid="${BASH_REMATCH[1]}"
|
||||
inhibit_start_time="${BASH_REMATCH[2]}"
|
||||
inhibit_owner="${BASH_REMATCH[3]}"
|
||||
process_matches "$inhibit_pid" "$inhibit_start_time" "$inhibit_owner" "$token" && break
|
||||
fi
|
||||
if [[ -n $launcher_pid ]] && ! kill -0 "$launcher_pid" 2>/dev/null; then
|
||||
wait "$launcher_pid" || return 1
|
||||
echo "The update sleep inhibitor did not start." >&2
|
||||
return 1
|
||||
fi
|
||||
(( readiness_attempts += 1 ))
|
||||
if [[ -t 0 ]] && (( EUID != 0 && readiness_attempts >= 100 )); then
|
||||
echo "The update sleep inhibitor did not become ready." >&2
|
||||
return 1
|
||||
fi
|
||||
/usr/bin/sleep 0.05
|
||||
done
|
||||
|
||||
if [[ -e $launch_control_file || -L $launch_control_file ]]; then
|
||||
state_file_is_private "$launch_control_file" || return 1
|
||||
rm -f -- "$launch_control_file"
|
||||
fi
|
||||
if [[ ! -f $stay_awake_state ]]; then
|
||||
printf '%s\n' "$idle_owner" >"$idle_owner_file"
|
||||
mkdir -p "$(dirname "$stay_awake_state")"
|
||||
if omarchy-toggle-idle stay-awake >/dev/null 2>&1; then
|
||||
printf '%s\n' "$idle_owner" >"$stay_awake_state"
|
||||
else
|
||||
rm -f "$idle_owner_file"
|
||||
atomic_write_state "$idle_owner_file" "$idle_owner" || return 1
|
||||
mkdir -p "$(dirname "$stay_awake_state")" || return 1
|
||||
# The idle toggle uses this marker. Publish its owner in the same write so
|
||||
# cancellation cannot leave an unowned Stay Awake setting behind.
|
||||
if ! printf '%s\n' "$idle_owner" >"$stay_awake_state"; then
|
||||
omarchy-toggle-idle allow-idle >/dev/null 2>&1 || true
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
launch_pending=0
|
||||
omarchy_security_install_sudo_cleanup_traps
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
start)
|
||||
start
|
||||
;;
|
||||
stop)
|
||||
stop
|
||||
;;
|
||||
start | stop) ;;
|
||||
*)
|
||||
echo "Usage: omarchy-update-stay-awake <start|stop>" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
initialize_state_boundary
|
||||
exec {state_lock_fd}<"$state_base" || fail_state_boundary
|
||||
flock -x "$state_lock_fd" || fail_state_boundary
|
||||
directory_is_private "$state_base" "$caller_uid" || fail_state_boundary
|
||||
|
||||
case "$1" in
|
||||
start)
|
||||
start_locked
|
||||
;;
|
||||
stop)
|
||||
stop_locked
|
||||
;;
|
||||
esac
|
||||
Reference in new issue
Block a user