create the user account first, and fix the sudoers filename bug that found

The user account section is now the first thing that acts, ahead of disk space.

The reason is a real defect, not tidiness. If the account does not exist yet,
USER_HOME is a path that is not there — and the ballast offers to put its file in
it, where ballast_create's `mkdir -p` runs as root and creates /home/<name> owned
by root:root. adduser afterwards finds the directory already present and does not
populate or chown it, so the account ends up with a home it cannot write to.
Making the account before any step can write into its home removes the ordering
entirely.

USER_HOME is re-read from getent after adduser runs. Until that point it is the
/home/<name> guess, because there is nothing to look up; adduser is free to have
used something else and every later step writes there.

Found while testing that, and worse than the thing it was testing:

  local user="$1" dest="/etc/sudoers.d/99-${user}-nopasswd"

bash expands ${user} before the assignment to user has happened, so dest came out
as /etc/sudoers.d/99--nopasswd with the name missing. The rule inside was correct,
which is what made it invisible — visudo passes, sudo works, and the account
really does get passwordless sudo. What breaks is everything around it: every
account granted this way writes to that same file, so a second grant silently
overwrites the first and revokes it; and has_passwordless_sudo looks for
99-<user>-nopasswd, never finds it, and re-grants on every run forever.

Split into separate declarations, with the reason recorded where it happened, and
an empty username is now refused outright. Scanned the other lib files for the
same shape — the remaining multi-assignment locals only read positional
parameters, which is safe.

The stray /etc/sudoers.d/99--nopasswd this created on the dev box during testing
has been removed and visudo -c re-verified.

Verified with a real throwaway account: correctly reports not-granted before,
writes 99-msdemo-nopasswd as root:root 0440 with the right rule, reports granted
after, and leaves sudoers valid.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-12 18:31:29 +00:00
co-authored by Claude Opus 5
parent fbb90c917d
commit c0eb3e3a85
2 changed files with 108 additions and 87 deletions
+10 -1
View File
@@ -285,9 +285,18 @@ as_user() {
# places it with its mode in a single install(1) — so what lands in /etc is # places it with its mode in a single install(1) — so what lands in /etc is
# already known good and already 0440. # already known good and already 0440.
grant_passwordless_sudo() { grant_passwordless_sudo() {
local user="$1" dest="/etc/sudoers.d/99-${user}-nopasswd" tmp # Declared separately, deliberately. In `local a="$1" b="${a}"` bash expands
# $a before it has been assigned, so b comes out with the name missing — which
# here meant every account's rule landing in the same /etc/sudoers.d/99--nopasswd,
# each one silently overwriting the last, and has_passwordless_sudo never
# finding the file it was looking for.
local user="$1"
local dest="/etc/sudoers.d/99-${user}-nopasswd"
local tmp
tmp="$(mktemp)" tmp="$(mktemp)"
[[ -n "$user" ]] || fail "grant_passwordless_sudo needs a username"
echo "${user} ALL=(ALL) NOPASSWD: ALL" >"$tmp" echo "${user} ALL=(ALL) NOPASSWD: ALL" >"$tmp"
if ! visudo -c -f "$tmp" >/dev/null 2>&1; then if ! visudo -c -f "$tmp" >/dev/null 2>&1; then
+98 -86
View File
@@ -96,7 +96,92 @@ info "Refreshing the package index..."
pkg_refresh >/dev/null pkg_refresh >/dev/null
# ============================================================================= # =============================================================================
# 2. Disk space # 2. User account
# =============================================================================
# First of the acting sections, and before anything writes into a home.
#
# If the account does not exist yet, USER_HOME is a path that is not there —
# and the ballast's mkdir -p, running as root, would create it root-owned. adduser
# then finds the directory already present and does not populate or chown it. So
# the account is made before any step can put a file in its home.
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")
# Re-read it now the account is real. Until this point USER_HOME was the
# /home/<name> guess, since there was nothing to look up; adduser is free
# to have used something else, and every step after this writes there.
USER_HOME="$(getent passwd "$USERNAME" | cut -d: -f6)"
ok "home is ${USER_HOME}"
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
step_ok
fi
# =============================================================================
# 3. Disk space
# ============================================================================= # =============================================================================
# #
# First of the sections that change anything, because everything after it sizes # First of the sections that change anything, because everything after it sizes
@@ -191,7 +276,7 @@ if ! skip; then
fi fi
# ============================================================================= # =============================================================================
# 3. System update # 4. System update
# ============================================================================= # =============================================================================
# #
# Its own section because it is the only thing in the script that moves versions # Its own section because it is the only thing in the script that moves versions
@@ -231,7 +316,7 @@ if ! skip; then
fi fi
# ============================================================================= # =============================================================================
# 4. Core utils # 5. Core utils
# ============================================================================= # =============================================================================
# #
# What the distribution provides: the six this script would break without, and # What the distribution provides: the six this script would break without, and
@@ -246,7 +331,7 @@ if ! skip; then
fi fi
# ============================================================================= # =============================================================================
# 5. Command-line tools # 6. Command-line tools
# ============================================================================= # =============================================================================
# #
# A different thing from core utils, and kept apart from them: upstream binaries # A different thing from core utils, and kept apart from them: upstream binaries
@@ -264,7 +349,7 @@ fi
# ============================================================================= # =============================================================================
# 6. Locale # 7. Locale
# ============================================================================= # =============================================================================
# #
# LOCALE in the environment overrides the default. # LOCALE in the environment overrides the default.
@@ -302,7 +387,7 @@ if ! skip; then
fi fi
# ============================================================================= # =============================================================================
# 7. Timezone # 8. Timezone
# ============================================================================= # =============================================================================
# #
# TIMEZONE in the environment answers the prompt ahead of time. # TIMEZONE in the environment answers the prompt ahead of time.
@@ -368,7 +453,7 @@ if ! skip; then
fi fi
# ============================================================================= # =============================================================================
# 8. Swap # 9. Swap
# ============================================================================= # =============================================================================
# #
# Disk the kernel can park cold pages on when RAM fills, so a spike costs # Disk the kernel can park cold pages on when RAM fills, so a spike costs
@@ -422,7 +507,7 @@ if ! skip; then
fi fi
# ============================================================================= # =============================================================================
# 9. Emergency disk ballast # 10. Emergency disk ballast
# ============================================================================= # =============================================================================
# #
# Always offered, whatever the role — the role only decides which way the # Always offered, whatever the role — the role only decides which way the
@@ -546,7 +631,7 @@ elif ! skip; then
fi fi
# ============================================================================= # =============================================================================
# 10. earlyoom # 11. earlyoom
# ============================================================================= # =============================================================================
step "earlyoom" step "earlyoom"
@@ -581,7 +666,7 @@ if ! skip; then
fi fi
# ============================================================================= # =============================================================================
# 11. inotify watch limit # 12. inotify watch limit
# ============================================================================= # =============================================================================
step "inotify watch limit" step "inotify watch limit"
@@ -620,7 +705,7 @@ if ! skip; then
fi fi
# ============================================================================= # =============================================================================
# 12. Sleep and suspend # 13. Sleep and suspend
# ============================================================================= # =============================================================================
step "Sleep and suspend" step "Sleep and suspend"
@@ -688,7 +773,7 @@ elif ! skip; then
fi fi
# ============================================================================= # =============================================================================
# 13. Boot hang # 14. Boot hang
# ============================================================================= # =============================================================================
step "Boot hang" step "Boot hang"
@@ -743,79 +828,6 @@ 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
step_ok
fi
# ============================================================================= # =============================================================================
# NOT PORTED YET # NOT PORTED YET
# ============================================================================= # =============================================================================
@@ -830,7 +842,7 @@ fi
# ============================================================================= # =============================================================================
# 12. Summary # 15. Summary
# ============================================================================= # =============================================================================
echo "" echo ""