|
#!/usr/bin/env bash |
|
set -Eeuo pipefail |
|
|
|
readonly DEFAULT_ALIASES_URL="https://gist.githubusercontent.com/lbussy/23c05d8dc8c24d8d8edddf1d381f1c8b/raw/my_aliases.sh" |
|
readonly DEFAULT_WHITESPACE_URL="https://gist.githubusercontent.com/lbussy/a0c00aa8b5f68d0aeb2e2c6dcd1676e8/raw/whitespace_clean" |
|
|
|
DRY_RUN="${DRY_RUN:-false}" |
|
ENABLE_PASSWORDLESS_SUDO="${ENABLE_PASSWORDLESS_SUDO:-false}" |
|
ENABLE_SYSTEM_MAINTENANCE="${ENABLE_SYSTEM_MAINTENANCE:-false}" |
|
BASH_ALIASES_URL="${BASH_ALIASES_URL:-$DEFAULT_ALIASES_URL}" |
|
WHITESPACE_CLEAN_URL="${WHITESPACE_CLEAN_URL:-$DEFAULT_WHITESPACE_URL}" |
|
OS_RELEASE_FILE="/etc/os-release" |
|
|
|
TARGET_USER="" |
|
TARGET_UID="" |
|
TARGET_GROUP="" |
|
TARGET_HOME="" |
|
DISTRO_ID="" |
|
DISTRO_NAME="" |
|
DISTRO_VERSION="" |
|
WORK_DIR="" |
|
declare -a PRIV=() |
|
declare -a POSTFLIGHT_MESSAGES=() |
|
readonly -a BOOTSTRAP_COMMANDS=(bash chmod cp date env apt-get dpkg dpkg-query getent grep id install mktemp mv rm sed stat) |
|
|
|
RESET="" |
|
BOLD="" |
|
FGRED="" |
|
FGGRN="" |
|
FGGLD="" |
|
MOVE_UP="" |
|
CLEAR_LINE="" |
|
|
|
stream_supports_color() { |
|
local fd="$1" colors |
|
[[ -t "$fd" && -z "${NO_COLOR+x}" && "${TERM:-dumb}" != dumb ]] || return 1 |
|
command -v tput >/dev/null 2>&1 || return 1 |
|
colors="$(tput colors 2>/dev/null || printf 0)" |
|
[[ "$colors" =~ ^[0-9]+$ && "$colors" -ge 8 ]] |
|
} |
|
|
|
init_terminal() { |
|
if stream_supports_color 1 || stream_supports_color 2; then |
|
RESET="$(tput sgr0 2>/dev/null || true)" |
|
BOLD="$(tput bold 2>/dev/null || true)" |
|
FGRED="$(tput setaf 1 2>/dev/null || true)" |
|
FGGRN="$(tput setaf 2 2>/dev/null || true)" |
|
FGGLD="$(tput setaf 3 2>/dev/null || true)" |
|
MOVE_UP="$(tput cuu1 2>/dev/null || true)" |
|
CLEAR_LINE="$(tput el 2>/dev/null || true)" |
|
fi |
|
} |
|
|
|
tag() { |
|
local fd="$1" color="$2" label="$3" |
|
if stream_supports_color "$fd"; then |
|
printf '%b%b[%s]%b' "$BOLD" "$color" "$label" "$RESET" |
|
else |
|
printf '[%s]' "$label" |
|
fi |
|
} |
|
|
|
info() { tag 1 "$FGGRN" 'INFO '; printf ' %s\n' "$*"; } |
|
defer_info() { POSTFLIGHT_MESSAGES+=("$*"); } |
|
report_postflight() { |
|
local message |
|
for message in "$@"; do |
|
info "$message" |
|
done |
|
} |
|
warn() { { tag 2 "$FGGLD" 'WARN '; printf ' %s\n' "$*"; } >&2; } |
|
die() { { tag 2 "$FGRED" 'ERROR'; printf ' %s\n' "$*"; } >&2; exit 1; } |
|
|
|
parse_bool() { |
|
local name="$1" value="$2" |
|
case "$value" in |
|
true|false) ;; |
|
*) die "$name must be exactly 'true' or 'false' (received: $value)." ;; |
|
esac |
|
} |
|
|
|
show_command() { |
|
local arg |
|
local rendered=() |
|
for arg in "$@"; do |
|
printf -v arg '%q' "$arg" |
|
rendered+=("$arg") |
|
done |
|
printf '%s' "${rendered[*]}" |
|
} |
|
|
|
exec() { |
|
local description="$1" |
|
shift |
|
if [[ "$DRY_RUN" == true ]]; then |
|
tag 1 "$FGGLD" 'DRY ' |
|
printf ' %s: ' "$description" |
|
show_command "$@" |
|
printf '\n' |
|
return 0 |
|
fi |
|
|
|
local output status=0 |
|
output="$(mktemp "${WORK_DIR:-/tmp}/setup-command.XXXXXXXX")" || |
|
die "Unable to create command-output file." |
|
tag 1 "$FGGLD" '-' |
|
printf ' Running: %s.\n' "$description" |
|
if "$@" >"$output" 2>&1; then |
|
status=0 |
|
else |
|
status=$? |
|
fi |
|
if stream_supports_color 1 && [[ -n "$MOVE_UP" && -n "$CLEAR_LINE" ]]; then |
|
printf '%b%b' "$MOVE_UP" "$CLEAR_LINE" |
|
fi |
|
if ((status == 0)); then |
|
tag 1 "$FGGRN" '✔' |
|
printf ' Complete: %s.\n' "$description" |
|
else |
|
{ tag 2 "$FGRED" '✘'; printf ' Failed: %s (status %d).\n' "$description" "$status"; } >&2 |
|
if [[ -s "$output" ]]; then |
|
printf '%s\n' '--- command output ---' >&2 |
|
sed 's/^/ /' "$output" >&2 |
|
printf '%s\n' '--- end command output ---' >&2 |
|
fi |
|
fi |
|
rm -f -- "$output" |
|
return "$status" |
|
} |
|
|
|
exec_priv() { |
|
local description="$1" |
|
shift |
|
exec "$description" "${PRIV[@]}" "$@" |
|
} |
|
|
|
cleanup() { |
|
local status=$? |
|
if [[ -n "$WORK_DIR" && -d "$WORK_DIR" ]]; then |
|
rm -rf -- "$WORK_DIR" || warn "Unable to remove temporary directory: $WORK_DIR" |
|
fi |
|
return "$status" |
|
} |
|
on_signal() { |
|
local signal="$1" |
|
warn "Interrupted by $signal." |
|
case "$signal" in |
|
HUP) exit 129 ;; |
|
INT) exit 130 ;; |
|
TERM) exit 143 ;; |
|
esac |
|
} |
|
trap cleanup EXIT |
|
trap 'on_signal HUP' HUP |
|
trap 'on_signal INT' INT |
|
trap 'on_signal TERM' TERM |
|
|
|
require_commands() { |
|
local command |
|
local missing=() |
|
# curl is installed below before the first in-script download. Keeping it |
|
# out of this bootstrap list lets minimal systems start via wget. |
|
for command in "${BOOTSTRAP_COMMANDS[@]}"; do |
|
command -v "$command" >/dev/null 2>&1 || missing+=("$command") |
|
done |
|
(("${#missing[@]}" == 0)) || die "Missing required commands: ${missing[*]}" |
|
} |
|
|
|
detect_distribution() { |
|
[[ -r "$OS_RELEASE_FILE" ]] || die "Cannot read $OS_RELEASE_FILE." |
|
local ID="" ID_LIKE="" NAME="" PRETTY_NAME="" VERSION_ID="" |
|
# os-release is an operating-system-owned shell-compatible data file. |
|
# shellcheck disable=SC1090 |
|
. "$OS_RELEASE_FILE" |
|
DISTRO_ID="${ID,,}" |
|
DISTRO_NAME="${PRETTY_NAME:-${NAME:-$DISTRO_ID}}" |
|
DISTRO_VERSION="${VERSION_ID:-unknown}" |
|
case "$DISTRO_ID" in |
|
debian|ubuntu|raspbian) ;; |
|
*) |
|
case " ${ID_LIKE,,} " in |
|
*" debian "*) ;; |
|
*) die "Unsupported distribution ID '$DISTRO_ID' (ID_LIKE='${ID_LIKE:-}')." ;; |
|
esac |
|
;; |
|
esac |
|
command -v apt-get >/dev/null 2>&1 || die "This Debian-family system lacks apt-get." |
|
info "Detected $DISTRO_NAME (version $DISTRO_VERSION)." |
|
} |
|
|
|
validate_username() { |
|
local user="$1" |
|
[[ "$user" =~ ^[a-z_][a-z0-9_-]*[$]?$ ]] || die "Unsafe or unsupported account name: '$user'." |
|
[[ "$user" != root ]] || die "Refusing to install user configuration or NOPASSWD policy for root." |
|
} |
|
|
|
select_target_username() { |
|
local effective_uid="$1" current_user="$2" sudo_user="$3" |
|
if ((effective_uid == 0)); then |
|
[[ -n "$sudo_user" ]] || return 1 |
|
printf '%s\n' "$sudo_user" |
|
else |
|
printf '%s\n' "$current_user" |
|
fi |
|
} |
|
|
|
validate_sudo_identity() { |
|
local target_uid="$1" sudo_uid="$2" |
|
[[ "$sudo_uid" =~ ^[0-9]+$ && "$sudo_uid" != 0 && "$target_uid" == "$sudo_uid" ]] |
|
} |
|
|
|
resolve_target_user() { |
|
local passwd_record current_user home_owner |
|
current_user="$(id -un)" || die "Unable to determine the current account." |
|
TARGET_USER="$(select_target_username "$EUID" "$current_user" "${SUDO_USER:-}")" || |
|
die "Run through sudo from the intended account; SUDO_USER is unavailable." |
|
validate_username "$TARGET_USER" |
|
passwd_record="$(getent passwd "$TARGET_USER")" || die "Account '$TARGET_USER' does not exist." |
|
IFS=: read -r _ _ TARGET_UID _ _ TARGET_HOME _ <<<"$passwd_record" |
|
[[ "$TARGET_UID" =~ ^[0-9]+$ ]] || die "Account '$TARGET_USER' has an invalid numeric UID." |
|
if ((EUID == 0)); then |
|
validate_sudo_identity "$TARGET_UID" "${SUDO_UID:-}" || |
|
die "SUDO_USER '$TARGET_USER' does not match SUDO_UID '${SUDO_UID:-unset}'." |
|
fi |
|
[[ -n "$TARGET_HOME" && "$TARGET_HOME" == /* && "$TARGET_HOME" != / ]] || die "Account '$TARGET_USER' has an unsafe home directory: '$TARGET_HOME'." |
|
[[ -d "$TARGET_HOME" && ! -L "$TARGET_HOME" ]] || die "Home for '$TARGET_USER' must be an existing, non-symlink directory." |
|
home_owner="$(stat -c %u -- "$TARGET_HOME")" || die "Unable to inspect ownership of '$TARGET_HOME'." |
|
[[ "$home_owner" == "$TARGET_UID" ]] || die "Home '$TARGET_HOME' is not owned by '$TARGET_USER'." |
|
TARGET_GROUP="$(id -gn "$TARGET_USER")" || die "Unable to determine the primary group for '$TARGET_USER'." |
|
info "User configuration target: $TARGET_USER ($TARGET_HOME)." |
|
} |
|
|
|
configure_privilege() { |
|
if ((EUID == 0)); then |
|
PRIV=() |
|
info "Running as root through sudo." |
|
return 0 |
|
fi |
|
command -v sudo >/dev/null 2>&1 || die "Run this installer with sudo, or install sudo first." |
|
sudo -v || die "This installer requires sudo privileges." |
|
PRIV=(sudo) |
|
if [[ -t 0 ]]; then |
|
local answer |
|
read -r -p "This script will modify the system. Continue? [y/N]: " answer |
|
[[ "${answer,,}" == y || "${answer,,}" == yes ]] || die "Cancelled." |
|
else |
|
die "Noninteractive execution must use the documented 'curl ... | sudo bash' form." |
|
fi |
|
} |
|
|
|
create_work_dir() { |
|
local temp_owner temp_mode |
|
if [[ "$DRY_RUN" == true ]]; then |
|
return 0 |
|
fi |
|
[[ -d /tmp && ! -L /tmp ]] || die "/tmp must be a real directory." |
|
temp_owner="$(stat -c %u -- /tmp)" || die "Unable to inspect /tmp ownership." |
|
temp_mode="$(stat -c %a -- /tmp)" || die "Unable to inspect /tmp permissions." |
|
[[ "$temp_owner" == 0 && "$temp_mode" =~ ^1[0-7]{3}$ ]] || |
|
die "/tmp must be root-owned and have the sticky bit set." |
|
WORK_DIR="$(mktemp -d /tmp/setup-pi.XXXXXXXX)" || die "Unable to create a temporary directory." |
|
chmod 700 "$WORK_DIR" |
|
} |
|
|
|
apt_install_dependencies() { |
|
local packages=(ca-certificates colordiff curl git gh htop needrestart sudo) |
|
exec_priv "Refresh APT package metadata" apt-get update |
|
exec_priv "Install required packages" env DEBIAN_FRONTEND=noninteractive apt-get install -y "${packages[@]}" |
|
} |
|
|
|
report_preflight() { |
|
if [[ "$DRY_RUN" == true ]]; then |
|
info "Dry run enabled: no changes will be made." |
|
else |
|
info "Dry run disabled." |
|
fi |
|
|
|
if [[ "$ENABLE_SYSTEM_MAINTENANCE" == true ]]; then |
|
warn "System maintenance enabled: upgrades allowed." |
|
else |
|
info "System maintenance disabled." |
|
fi |
|
|
|
if [[ "$ENABLE_PASSWORDLESS_SUDO" == true ]]; then |
|
warn "Passwordless sudo enabled for $TARGET_USER." |
|
else |
|
info "Passwordless sudo disabled." |
|
fi |
|
} |
|
|
|
run_system_maintenance() { |
|
if [[ "$ENABLE_SYSTEM_MAINTENANCE" != true ]]; then |
|
return 0 |
|
fi |
|
exec_priv "Repair broken package state" env DEBIAN_FRONTEND=noninteractive apt-get --fix-broken install -y |
|
exec_priv "Perform full system upgrade" env DEBIAN_FRONTEND=noninteractive apt-get full-upgrade -y |
|
exec_priv "Remove obsolete packages" env DEBIAN_FRONTEND=noninteractive apt-get autoremove --purge -y |
|
exec_priv "Clean APT package cache" apt-get clean |
|
} |
|
|
|
backup_existing_aliases() { |
|
local destination="$1" backup stamp |
|
[[ -e "$destination" || -L "$destination" ]] || return 0 |
|
[[ -f "$destination" && ! -L "$destination" ]] || |
|
die "Refusing to back up non-regular or symlinked aliases file: $destination" |
|
stamp="$(date -u +%Y%m%dT%H%M%SZ)" |
|
backup="${destination}.backup.${stamp}" |
|
[[ ! -e "$backup" ]] || die "Backup destination already exists: $backup" |
|
exec_priv "Back up existing .bash_aliases" install -o "$TARGET_USER" -g "$TARGET_GROUP" -m 0600 "$destination" "$backup" |
|
} |
|
|
|
install_aliases() { |
|
local downloaded destination="$TARGET_HOME/.bash_aliases" |
|
local personal="$TARGET_HOME/.personal_aliases" |
|
if [[ "$DRY_RUN" == true ]]; then |
|
defer_info "Aliases download planned." |
|
defer_info "Alias backup and installation planned for $TARGET_USER." |
|
return 0 |
|
fi |
|
if [[ -e "$personal" || -L "$personal" ]]; then |
|
[[ -f "$personal" && ! -L "$personal" ]] || |
|
die "Refusing non-regular or symlinked .personal_aliases." |
|
fi |
|
downloaded="$WORK_DIR/bash_aliases" |
|
exec "Download Bash aliases" curl -fL --proto '=https' --tlsv1.2 "$BASH_ALIASES_URL" -o "$downloaded" |
|
[[ -s "$downloaded" ]] || die "Downloaded aliases file is empty." |
|
bash -n "$downloaded" || die "Downloaded aliases file is not valid Bash." |
|
backup_existing_aliases "$destination" |
|
exec_priv "Install .bash_aliases" install -o "$TARGET_USER" -g "$TARGET_GROUP" -m 0600 "$downloaded" "$destination" |
|
if [[ ! -e "$personal" ]]; then |
|
exec_priv "Create .personal_aliases" install -o "$TARGET_USER" -g "$TARGET_GROUP" -m 0600 /dev/null "$personal" |
|
else |
|
defer_info "Existing .personal_aliases preserved." |
|
fi |
|
} |
|
|
|
write_helper() { |
|
local options="$1" output="$2" |
|
cat >"$output" <<EOF |
|
#!/usr/bin/env bash |
|
set -uo pipefail |
|
target="\${1:-.}" |
|
ls $options --color=always -- "\$target" 2>/dev/null | sed -E 's/ -> .*//' |
|
EOF |
|
bash -n "$output" |
|
} |
|
|
|
install_helpers() { |
|
local helper |
|
if [[ "$DRY_RUN" == true ]]; then |
|
defer_info "Helper installation planned in /usr/local/bin." |
|
return 0 |
|
fi |
|
helper="$WORK_DIR/la" |
|
write_helper "-hal" "$helper" || die "Unable to generate la helper." |
|
exec_priv "Install la helper" install -o root -g root -m 0755 "$helper" /usr/local/bin/la |
|
helper="$WORK_DIR/ll" |
|
write_helper "-hl" "$helper" || die "Unable to generate ll helper." |
|
exec_priv "Install ll helper" install -o root -g root -m 0755 "$helper" /usr/local/bin/ll |
|
helper="$WORK_DIR/whitespace_clean" |
|
exec "Download whitespace_clean" curl -fL --proto '=https' --tlsv1.2 "$WHITESPACE_CLEAN_URL" -o "$helper" |
|
[[ -s "$helper" ]] || die "Downloaded whitespace_clean is empty." |
|
bash -n "$helper" || die "Downloaded whitespace_clean is not valid Bash." |
|
exec_priv "Install whitespace_clean" install -o root -g root -m 0755 "$helper" /usr/local/bin/whitespace_clean |
|
} |
|
|
|
sudoers_rule_exists() { |
|
local escaped_user |
|
escaped_user="$(printf '%s' "$TARGET_USER" | sed 's/[][\\.^$*+?{}|()]/\\&/g')" |
|
"${PRIV[@]}" grep -ERqs "^[[:space:]]*${escaped_user}[[:space:]]+ALL[[:space:]]*=\\(ALL(:ALL)?\\)[[:space:]]+NOPASSWD:[[:space:]]*ALL[[:space:]]*$" /etc/sudoers /etc/sudoers.d 2>/dev/null |
|
} |
|
|
|
render_sudoers_rule() { |
|
printf '%s ALL=(ALL:ALL) NOPASSWD: ALL\n' "$1" |
|
} |
|
|
|
install_passwordless_sudo() { |
|
if [[ "$ENABLE_PASSWORDLESS_SUDO" != true ]]; then |
|
return 0 |
|
fi |
|
if [[ "$DRY_RUN" == true ]]; then |
|
defer_info "Passwordless sudo installation planned for $TARGET_USER." |
|
return 0 |
|
fi |
|
command -v visudo >/dev/null 2>&1 || die "visudo is required when ENABLE_PASSWORDLESS_SUDO=true." |
|
if sudoers_rule_exists; then |
|
defer_info "An equivalent NOPASSWD rule already exists for $TARGET_USER." |
|
return 0 |
|
fi |
|
|
|
local candidate="$WORK_DIR/sudoers-candidate" |
|
local destination="/etc/sudoers.d/010-${TARGET_USER}-nopasswd" |
|
local stage backup="" |
|
render_sudoers_rule "$TARGET_USER" >"$candidate" |
|
chmod 0600 "$candidate" |
|
visudo -cf "$candidate" >/dev/null || die "Generated sudoers rule failed validation." |
|
|
|
if "${PRIV[@]}" test -e "$destination"; then |
|
backup="$WORK_DIR/sudoers-existing" |
|
"${PRIV[@]}" cp -p "$destination" "$backup" || die "Unable to back up existing sudoers rule." |
|
fi |
|
stage="$("${PRIV[@]}" mktemp "/etc/sudoers.d/.010-${TARGET_USER}-nopasswd.XXXXXXXX")" || die "Unable to stage sudoers rule." |
|
if ! "${PRIV[@]}" install -o root -g root -m 0440 "$candidate" "$stage" || |
|
! "${PRIV[@]}" mv -f "$stage" "$destination" || |
|
! "${PRIV[@]}" visudo -cf /etc/sudoers >/dev/null; then |
|
"${PRIV[@]}" rm -f -- "$stage" "$destination" |
|
if [[ -n "$backup" ]]; then |
|
"${PRIV[@]}" install -o root -g root -m 0440 "$backup" "$destination" |
|
fi |
|
die "Sudoers installation failed validation and was rolled back." |
|
fi |
|
defer_info "Passwordless sudo rule installed for $TARGET_USER." |
|
} |
|
|
|
report_needrestart_policy() { |
|
case "$DISTRO_ID" in |
|
raspbian) defer_info "Raspberry Pi OS needrestart policy retained." ;; |
|
*) defer_info "Distribution needrestart policy retained." ;; |
|
esac |
|
} |
|
|
|
main() { |
|
init_terminal |
|
parse_bool DRY_RUN "$DRY_RUN" |
|
parse_bool ENABLE_PASSWORDLESS_SUDO "$ENABLE_PASSWORDLESS_SUDO" |
|
parse_bool ENABLE_SYSTEM_MAINTENANCE "$ENABLE_SYSTEM_MAINTENANCE" |
|
require_commands |
|
detect_distribution |
|
resolve_target_user |
|
configure_privilege |
|
create_work_dir |
|
|
|
report_preflight |
|
|
|
apt_install_dependencies |
|
run_system_maintenance |
|
install_aliases |
|
install_helpers |
|
install_passwordless_sudo |
|
report_needrestart_policy |
|
report_postflight "${POSTFLIGHT_MESSAGES[@]}" |
|
|
|
info "Setup completed successfully." |
|
info "Reload aliases: source ~/.bash_aliases" |
|
} |
|
|
|
if [[ "${BASH_SOURCE[0]:-$0}" == "$0" ]]; then |
|
main "$@" |
|
fi |