422 lines
14 KiB
Bash
Executable File
422 lines
14 KiB
Bash
Executable File
#!/bin/bash -p
|
|
|
|
# omarchy:summary=Toggle passwordless sudo for the current user.
|
|
# omarchy:args=[MINUTES]
|
|
# omarchy:requires-sudo=true
|
|
|
|
if [[ $- != *p* && ${BASH_SOURCE[0]} == "$0" ]]; then
|
|
echo "Refusing an unsafe Bash startup for passwordless sudo." >&2
|
|
exit 126
|
|
fi
|
|
|
|
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 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 names and the legacy filename both have metacharacters. Omarchy
|
|
# accounts use this portable 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
|
|
/usr/bin/flock -x "$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
|
|
}
|
|
|
|
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-}
|
|
|
|
if [[ $suffix =~ ^[0-9]+$ ]]; then
|
|
name=${contents%' ALL=(ALL) NOPASSWD: ALL'}
|
|
if valid_account_name "$name" && [[ $contents == "$name ALL=(ALL) NOPASSWD: ALL" ]]; then
|
|
return 0
|
|
fi
|
|
name=${contents%%' ALL=(ALL) NOTAFTER='*}
|
|
valid_account_name "$name" && [[ $contents =~ ^[a-z_][a-z0-9_-]*\$?\ ALL=\(ALL\)\ NOTAFTER=[0-9]{14}Z\ NOPASSWD:\ ALL$ ]]
|
|
elif valid_account_name "$suffix" && [[ $contents == "$suffix ALL=(ALL) NOPASSWD: ALL" ]]; then
|
|
return 0
|
|
else
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
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 ]]
|
|
}
|
|
|
|
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=$?
|
|
(( classification == 1 )) || failed=1
|
|
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..."
|
|
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 ""
|
|
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 "including if the machine reboots before the deadline."
|
|
echo "Run this command again to disable it early."
|
|
echo ""
|
|
|
|
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."
|
|
else
|
|
echo "Aborted. No changes made."
|
|
fi
|
|
fi
|