port the user account section, and stop clobbering files in the home

Two real defects fixed on the way across.

The sudoers write was in the wrong order. The original echoed the rule straight
into /etc/sudoers.d, validated it afterwards, and chmod'd it later still. A
malformed file there breaks sudo COMPLETELY — and you cannot sudo to repair it,
so on a remote machine that is a rescue console — and so does one with loose
permissions, because sudo refuses to read its own configuration. Both of those
windows were live in the original ordering. grant_passwordless_sudo now writes a
temp file, runs visudo -c against it, and only then places it with install(1),
which applies the content and the 0440 mode in one step. Nothing reaches
/etc/sudoers.d that has not already been validated.

The .tmux.conf copy overwrote whatever was in the home on every run. lib/files.sh
adds the two shapes that stop this whole class of thing:

  install_config  installs when absent, does nothing when identical, and keeps
                  what the user wrote when it differs — printing the cp to take
                  ours, so the choice stays theirs
  append_once     wraps a block in named markers so a second run recognises its
                  own work; also lets a human see which lines came from this
                  script and remove them as a unit

append_once is what the five unguarded `cat >>` into .zshrc need when those
sections are ported — a second pass currently duplicates the starship init, the
nvim PATH, bun, deno and the aliases.

Passwordless sudo is asked separately from creating the account, because it is a
security posture rather than part of making a user, and the cost is stated: a key
that can log into this account is root without a further step. Officer's actual
requirement is stated too — os-user-shell.ts runs `sudo -n`, and a prompt it
cannot answer surfaces as a permissions error rather than a question — and
refusing records that consequence in the summary instead of a bare "skipped".

Verified: all three install_config outcomes, append_once writing exactly once
across two runs, visudo rejecting junk before anything is installed, and the
section reporting correctly against this host's existing account.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-12 18:22:40 +00:00
co-authored by Claude Opus 5
parent 458510a0a8
commit 3fb0e5c887
3 changed files with 200 additions and 1 deletions
+37
View File
@@ -212,6 +212,43 @@ as_user() {
sudo -u "$USERNAME" -i bash -c "$1" 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 # Operating system detection
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
+75
View File
@@ -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"
}
+88 -1
View File
@@ -26,6 +26,8 @@ source "$SCRIPT_DIR/lib/tools.sh"
source "$SCRIPT_DIR/lib/system.sh" source "$SCRIPT_DIR/lib/system.sh"
# shellcheck source=lib/disk.sh # shellcheck source=lib/disk.sh
source "$SCRIPT_DIR/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 # 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. # 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 step_ok
fi 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 # NOT PORTED YET
# ============================================================================= # =============================================================================
# #
# Sections still to move across from scripts/setup-old/setup-ubuntu.sh, in order: # 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 · # ssh keys · ssh hardening · dns · static ip · fail2ban · unattended-upgrades ·
# git config · docker · zsh + prompt · tailscale · neovim · js runtimes · # git config · docker · zsh + prompt · tailscale · neovim · js runtimes ·
# dev tools · ufw · zshrc # dev tools · ufw · zshrc