diff --git a/scripts/setup/machine-setup/lib/base.sh b/scripts/setup/machine-setup/lib/base.sh index 5d69311b..e6343b5c 100644 --- a/scripts/setup/machine-setup/lib/base.sh +++ b/scripts/setup/machine-setup/lib/base.sh @@ -212,6 +212,43 @@ as_user() { sudo -u "$USERNAME" -i bash -c "$1" } +# ----------------------------------------------------------------------------- +# sudoers +# ----------------------------------------------------------------------------- + +# Grant an account passwordless sudo, safely. +# +# A malformed file in /etc/sudoers.d breaks sudo COMPLETELY — and you cannot sudo +# to repair it, so on a remote machine that is unrecoverable short of a rescue +# console. The same is true of one with loose permissions: sudo refuses to read +# its own configuration and every sudo on the box fails. +# +# The original wrote the file into /etc/sudoers.d first and validated it after, +# with a chmod later still. Both of those leave a window where a broken or +# world-readable sudoers file is live. This validates a temp file first and then +# places it with its mode in a single install(1) — so what lands in /etc is +# already known good and already 0440. +grant_passwordless_sudo() { + local user="$1" dest="/etc/sudoers.d/99-${user}-nopasswd" tmp + tmp="$(mktemp)" + + echo "${user} ALL=(ALL) NOPASSWD: ALL" >"$tmp" + + if ! visudo -c -f "$tmp" >/dev/null 2>&1; then + rm -f "$tmp" + fail "visudo rejected the sudoers entry for '${user}' — not installing it" + fi + + install -m 0440 -o root -g root "$tmp" "$dest" + rm -f "$tmp" +} + +has_passwordless_sudo() { + local user="$1" + [[ -f "/etc/sudoers.d/99-${user}-nopasswd" ]] || + grep -rqsE "^${user}[[:space:]]+ALL=\(ALL\)[[:space:]]+NOPASSWD" /etc/sudoers /etc/sudoers.d 2>/dev/null +} + # ----------------------------------------------------------------------------- # Operating system detection # ----------------------------------------------------------------------------- diff --git a/scripts/setup/machine-setup/lib/files.sh b/scripts/setup/machine-setup/lib/files.sh new file mode 100644 index 00000000..3046e883 --- /dev/null +++ b/scripts/setup/machine-setup/lib/files.sh @@ -0,0 +1,75 @@ +#!/bin/bash +# ============================================================================= +# machine-setup — writing files into somebody's home +# ============================================================================= +# +# Definitions only, like the other lib/ files. +# +# ── The rule ── +# +# A setup script may create a config file. It may not silently replace one the +# user wrote. The original did the second: `cp .tmux.conf $USER_HOME/` on every +# run, over whatever was there, and five separate `cat >>` into .zshrc with no +# guard — so a second pass duplicated the starship init, the nvim PATH, bun, deno +# and the aliases. +# +# Both of those are the same mistake in different shapes: writing without looking +# first. The two helpers here are the two safe shapes. + +[[ -n "${MACHINE_SETUP_FILES_LOADED:-}" ]] && return 0 +MACHINE_SETUP_FILES_LOADED=1 + +# Put a config file in place, unless the user has their own. +# +# Three outcomes, and the caller can tell them apart by the return code: +# +# 0 installed — there was nothing there +# 1 identical — already exactly this, nothing done +# 2 kept — theirs differs, left alone +# +# Converging when there is nothing to lose and keeping what the user wrote when +# there is. The third case prints how to take ours, so the choice stays with +# them. +install_config() { + local src="$1" dest="$2" owner="$3" + + if [[ -f "$dest" ]]; then + if cmp -s "$src" "$dest"; then + return 1 + fi + warn "kept your ${dest} — it differs from the one shipped here" + echo " to take ours instead: cp ${src} ${dest}" + return 2 + fi + + install -D -m 0644 -o "$owner" -g "$owner" "$src" "$dest" + return 0 +} + +# Append a block to a file exactly once. +# +# The block is wrapped in markers naming what it is, so a second run recognises +# its own work instead of adding it again — and so a human reading the file can +# see which lines came from here and delete them as a unit. +# +# append_once ~/.zshrc bun <<'EOF' +# export PATH="$HOME/.bun/bin:$PATH" +# EOF +# +# Returns 0 if it wrote, 1 if the block was already there. +append_once() { + local file="$1" name="$2" + local begin="# >>> machine-setup: ${name} >>>" + local end="# <<< machine-setup: ${name} <<<" + + if [[ -f "$file" ]] && grep -qF "$begin" "$file"; then + return 1 + fi + + { + echo "" + echo "$begin" + cat + echo "$end" + } >>"$file" +} diff --git a/scripts/setup/machine-setup/machine-setup.sh b/scripts/setup/machine-setup/machine-setup.sh index fc4bd05f..bbb3a0dd 100755 --- a/scripts/setup/machine-setup/machine-setup.sh +++ b/scripts/setup/machine-setup/machine-setup.sh @@ -26,6 +26,8 @@ source "$SCRIPT_DIR/lib/tools.sh" source "$SCRIPT_DIR/lib/system.sh" # shellcheck source=lib/disk.sh source "$SCRIPT_DIR/lib/disk.sh" +# shellcheck source=lib/files.sh +source "$SCRIPT_DIR/lib/files.sh" # Trap errors with context. Installed here rather than in lib/base.sh, because # that file is definitions only and a trap is a side effect on whoever sources it. @@ -741,13 +743,98 @@ elif ! skip; then step_ok fi +# ============================================================================= +# 14. User account +# ============================================================================= + +step "User account" +if ! skip; then + echo "" + info "User account — the account you will actually log in and work as" + + if id "$USERNAME" &>/dev/null; then + echo " ${USERNAME} already exists" + USER_EXISTED=true + else + echo " ${USERNAME} does not exist yet and will be created" + echo " adduser will ask for a password and a few details" + USER_EXISTED=false + fi + + IN_SUDO=false + id -nG "$USERNAME" 2>/dev/null | tr ' ' '\n' | grep -qx sudo && IN_SUDO=true + echo " sudo group: $($IN_SUDO && echo 'already a member' || echo 'will be added')" + echo " passwordless: $(has_passwordless_sudo "$USERNAME" && echo 'already granted' || echo 'not granted')" + + if $USER_EXISTED && $IN_SUDO; then + SUMMARY+=("User: ${USERNAME} (already set up)") + elif confirm "Proceed?"; then + if ! $USER_EXISTED; then + adduser --gecos "" "$USERNAME" + SUMMARY+=("User: ${USERNAME} created") + fi + $IN_SUDO || usermod -aG sudo "$USERNAME" + ok "${USERNAME} is in the sudo group" + $USER_EXISTED && SUMMARY+=("User: ${USERNAME} added to sudo") + else + warn "skipped by request" + SUMMARY+=("User: SKIPPED by request") + fi + + # ── passwordless sudo ── + # + # Asked separately because it is a security posture rather than part of + # creating an account, and because Officer has an actual requirement here: + # os-user-shell.ts runs `sudo -n` to provision a member's home, and a prompt it + # cannot answer is a failure it reports as a permissions error. + if has_passwordless_sudo "$USERNAME"; then + echo "" + echo " passwordless sudo is already granted to ${USERNAME}" + SUMMARY+=("Sudo: passwordless (already)") + else + echo "" + info "Passwordless sudo for ${USERNAME}?" + echo " Means sudo never asks for a password again. Convenient, and the" + echo " cost is real: anything that gets hold of this account, or of a key" + echo " that can log into it, is root without another step." + if is_role vps; then + echo " Worth weighing on a ${MACHINE_ROLE}, which faces the open internet." + fi + echo "" + echo " Officer needs it: it runs 'sudo -n' to provision a member's Linux" + echo " account, and a password prompt it cannot answer surfaces as a" + echo " permissions error rather than a question." + if confirm "Grant it?"; then + grant_passwordless_sudo "$USERNAME" + ok "passwordless sudo granted — remove /etc/sudoers.d/99-${USERNAME}-nopasswd to undo" + SUMMARY+=("Sudo: passwordless") + else + warn "skipped by request" + SUMMARY+=("Sudo: password required (Officer's account provisioning will not work)") + fi + fi + + # ── tmux config ── + # + # install_config rather than cp: the original overwrote whatever was there on + # every single run. + if [[ -f "$SCRIPT_DIR/.tmux.conf" ]] && id "$USERNAME" &>/dev/null; then + install_config "$SCRIPT_DIR/.tmux.conf" "${USER_HOME}/.tmux.conf" "$USERNAME" + case $? in + 0) ok "tmux config installed" ;; + 1) : ;; + 2) : ;; + esac + fi + step_ok +fi + # ============================================================================= # NOT PORTED YET # ============================================================================= # # Sections still to move across from scripts/setup-old/setup-ubuntu.sh, in order: # -# user creation · # ssh keys · ssh hardening · dns · static ip · fail2ban · unattended-upgrades · # git config · docker · zsh + prompt · tailscale · neovim · js runtimes · # dev tools · ufw · zshrc