Compare commits

...

5 commits

Author SHA1 Message Date
cb4de44bf4 Merge pull request 'fix: refuse a PATH without /usr/sbin, before the token prompt' (#140) from build/139-sbin-path-preflight into main
All checks were successful
ci / check (push) Successful in 56s
ci / install (push) Successful in 3s
ci / db-integration (push) Successful in 3s
release / release (push) Successful in 6s
Reviewed-on: #140
Reviewed-by: grok-reviewer-andresmgsl <andres+3@heavyduty.builders>
Reviewed-by: kimi-reviewer-andresmgsl <andres+4@heavyduty.builders>
Reviewed-by: codex-reviewer-andresmgsl <andres+2@heavyduty.builders>
2026-08-02 07:57:52 +00:00
7aed6ea098 fix: preflight every admin binary a command uses, not only useradd
Some checks failed
ci / check (pull_request) Successful in 55s
ci / install (pull_request) Successful in 3s
ci / db-integration (pull_request) Successful in 3s
labels / labels (pull_request) Failing after 7s
Addresses codex (1538) and kimi (1539): the sweep caught the reported
incident and not the class. Both are right, and there were four sites, not
three.

  forgejo-runner-install  useradd -> useradd usermod   (usermod -aG docker,
                          reached only after the token has been spent)
  users-apply             useradd usermod -> + groupadd (called two lines
                          into convergence), and visudo when a role needs it
  bootstrap-tenant        NEW site (kimi) — usermod -aG docker runs AFTER
                          docker and node are installed, so an unguarded
                          PATH fails it mid-convergence on a changed machine
  runner-install          unchanged: useradd is the only admin binary it
                          calls, and declaring more would refuse boxes that
                          are fine

visudo is checked after the sudo-install block rather than beside the root
check, because until sudo is installed its absence has an innocent cause.
Below that block it does not: sudo is present, so a missing visudo means
/usr/sbin is off PATH. That case is the quiet one — the sudoers block reads
`command -v visudo` as "no sudo on the box means no role needed it", so
apply reported success having granted roles without the escalation those
roles exist for. The other three sites at least crash.

Measured which binaries this covers (Debian 13): useradd, usermod, groupadd,
userdel, groupdel and visudo are /usr/sbin; gpasswd is /usr/bin and so is
NOT affected and deliberately not preflighted. visudo shares the directory
but ships in `sudo`, not `passwd` — which is why it needs its own treatment.

Tests: the sbin-less fixtures could only ever prove the FIRST binary is
named, since useradd wins every race. Six new checks use partial PATHs that
resolve the earlier binaries and withhold exactly one, plus the ordering
assertions (no token prompt, no group created) and the negative case — a
users file needing no sudo must NOT be refused for a missing visudo.

Refs #139
2026-08-02 00:05:02 +00:00
5187b74fa0 Merge remote-tracking branch 'origin/main' into pr140 2026-08-01 23:58:21 +00:00
0d36b4dc95 Merge pull request 'fix: slim ubuntu-latest default; install shellcheck in ci.yml (#144)' (#146) from build/144-default-labels-option-b into main
All checks were successful
ci / check (push) Successful in 55s
ci / install (push) Successful in 3s
ci / db-integration (push) Successful in 3s
release / release (push) Successful in 6s
Reviewed-on: #146
Reviewed-by: codex-reviewer-andresmgsl <andres+2@heavyduty.builders>
Reviewed-by: cluade-reviewer-andresmgsl <andres+1@heavyduty.builders>
Reviewed-by: kimi-reviewer-andresmgsl <andres+4@heavyduty.builders>
2026-08-01 23:23:41 +00:00
7f2501d0fe fix: refuse a PATH without /usr/sbin, before the token prompt
Some checks failed
ci / check (pull_request) Failing after 7s
ci / install (pull_request) Successful in 4s
ci / db-integration (pull_request) Successful in 4s
labels / labels (pull_request) Failing after 7s
Reported from a real ci-box: `rig forgejo-runner install` read a registration
token off the operator's terminal and then died with

  …/forgejo-runner-install.sh: line 250: useradd: command not found

rig checked `id -u` and concluded it could administer the machine. Being root
and being able to FIND the admin binaries are different facts, and only the
first was asserted. `su` without `-`, sudo with a sanitised secure_path, and
several container images all produce a root shell with no /usr/sbin on PATH,
which is where useradd lives.

Three call sites had it: both runner installers and users apply. The last is
the worst — it runs mid-convergence, so a PATH-shorn root could fail partway
through a user sweep rather than before it starts.

require_admin_bins refuses rather than repairing PATH itself: a command that
quietly prepends /usr/sbin teaches the operator nothing and leaves a
misconfigured host misconfigured. The message names the remedy and,
deliberately, not this script — echoing an internal path back at someone who
typed `rig forgejo-runner install` is the unhelpful half of the original error.

It sits beside each root check, so identity and capability are asserted
together and before anything is spent. A secret typed for a run that could
never succeed is the avoidable half of this bug, and there is a test for
exactly that ordering.

Closes #139

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 18:39:03 +00:00
7 changed files with 222 additions and 0 deletions

4
changelog.d/139.md Normal file
View file

@ -0,0 +1,4 @@
### Fixed
- `rig forgejo-runner install`, `rig runner install`, `rig users apply` and `rig bootstrap <tenant>` refuse with a named remedy when root's `PATH` carries no `/usr/sbin`, instead of dying on `useradd: command not found` after prompting for a token (#139)
- `rig users apply` no longer reports success having silently skipped the sudoers drop-in when `visudo` is off `PATH` (#139)

View file

@ -31,6 +31,8 @@ HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
. "$HERE/lib/templates.sh" # templates_resolve / template_parse_env / render_tenant_context
# shellcheck source=SCRIPTDIR/lib/users-config.sh
. "$HERE/lib/users-config.sh" # read_role_marker / root_door_of
# shellcheck source=SCRIPTDIR/lib/admin-path.sh
. "$HERE/lib/admin-path.sh" # require_admin_bins
# shellcheck source=SCRIPTDIR/lib/sshd.sh
. "$HERE/lib/sshd.sh" # harden_sshd (the staging-box tenant)
# shellcheck source=SCRIPTDIR/lib/manifest.sh
@ -200,6 +202,15 @@ else
fi
[ "$(id -u)" -eq 0 ] || die "must run as root"
# Root is not enough here either (#139). This mint adds the tenant user to the
# docker group with `usermod` far below — AFTER installing docker and node,
# which is what makes it the worst-placed of the four call sites: on a
# PATH-shorn root it dies mid-convergence with a bare `usermod: command not
# found`, having already changed the machine, rather than before touching it.
#
# Unconditional because the docker block below is unconditional — "every tenant
# gets docker" is the stated rule there, so every tenant reaches the usermod.
require_admin_bins usermod
if [ -r /etc/os-release ]; then
# Sourced in a subshell: os-release defines VERSION, NAME, ID, etc. —
# sourcing it in the main shell silently clobbers same-named script vars.

View file

@ -17,6 +17,8 @@ set -euo pipefail
HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
# shellcheck source=SCRIPTDIR/lib/forgejo-runner-config.sh
. "$HERE/lib/forgejo-runner-config.sh"
# shellcheck source=SCRIPTDIR/lib/admin-path.sh
. "$HERE/lib/admin-path.sh"
log() { printf 'rig-forgejo-runner: %s\n' "$*"; }
warn() { printf 'rig-forgejo-runner: WARNING: %s\n' "$*" >&2; }
@ -229,6 +231,17 @@ fi
# --- guards ----------------------------------------------------------------
[ "$(id -u)" -eq 0 ] || die "must run as root"
# Root is not enough: the admin binaries must also be reachable (#139). This
# sits beside the root check so identity and capability are asserted together,
# and BEFORE any prompt or download — a token typed for a doomed run is waste.
#
# BOTH binaries this command goes on to call: useradd at the user-create below,
# usermod at the docker-group add further down. Naming only the first would
# still consume the token on a PATH that happened to resolve useradd but not
# usermod — the same failure one step later, which is the shape #75 exists to
# refuse (a sweep that covers most of its call sites is the hole the next bug
# arrives through).
require_admin_bins useradd usermod
if [ -r /etc/os-release ]; then
# Sourced in a subshell: os-release defines VERSION (e.g. "13 (trixie)"),
# which would clobber this script's $VERSION.

View file

@ -0,0 +1,47 @@
#!/usr/bin/env bash
# admin-path.sh — assert the admin binaries are REACHABLE, not merely that we
# are root.
#
# Being uid 0 and being able to find useradd are different facts, and rig
# asserted only the first. `su` without `-`, sudo with a sanitised secure_path,
# and several container images all hand you a root shell whose PATH carries no
# /usr/sbin — which is where useradd, usermod and groupadd live on Debian. The
# result was a bare `useradd: command not found` naming a line number inside a
# versioned install root, emitted AFTER a registration token had been read off
# the operator's terminal (#139).
#
# Which binaries this covers is measured, not assumed (Debian 13, 2026-08-01):
#
# useradd usermod groupadd userdel groupdel /usr/sbin package: passwd
# visudo /usr/sbin package: sudo
# gpasswd /usr/bin package: passwd
#
# Two consequences worth keeping written down. `gpasswd` is in the same PACKAGE
# as useradd but a different DIRECTORY, so it is reachable on a PATH-shorn root
# and does not belong in any of these preflights — do not add it for symmetry.
# And `visudo` shares the directory but not the package, so its absence has a
# second, innocent cause (sudo simply not installed) that the others do not, so
# `rig users apply` checks it separately, after the point where that cause is
# ruled out. See the comment there.
#
# (Spelled without the `.sh` on purpose: test/cli.sh pins that exactly one file
# under commands/ names that script, to catch a second caller appearing. A
# comment is not a caller, but the pin is deliberately blunt and cheap.)
#
# It REFUSES rather than repairing PATH itself. A command that quietly prepends
# /usr/sbin teaches the operator nothing and leaves a misconfigured host
# misconfigured; the same reason bootstrap refuses rather than guessing. The
# message carries the fix so the refusal costs one paste, not an investigation.
# require_admin_bins <bin>... — die unless every one resolves on PATH.
require_admin_bins() {
local missing=() b
for b in "$@"; do
command -v "$b" >/dev/null 2>&1 || missing+=("$b")
done
[ "${#missing[@]}" -eq 0 ] && return 0
# Names the REMEDY, not this script: the operator typed a `rig ...` command,
# and echoing the internal path back at them is the unhelpful half of the
# original `useradd: command not found`.
die "cannot find ${missing[*]} on PATH — it lives in /usr/sbin, which this root shell does not carry (a 'su' without '-' does this, and so do some container images). Re-run the same rig command with: PATH=/usr/sbin:/sbin:\$PATH"
}

View file

@ -9,6 +9,8 @@ set -euo pipefail
HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
# shellcheck source=SCRIPTDIR/lib/runner-config.sh
. "$HERE/lib/runner-config.sh"
# shellcheck source=SCRIPTDIR/lib/admin-path.sh
. "$HERE/lib/admin-path.sh"
log() { printf 'rig-runner: %s\n' "$*"; }
warn() { printf 'rig-runner: WARNING: %s\n' "$*" >&2; }
@ -85,6 +87,10 @@ VERSION="${VERSION#v}"
# --- guards ----------------------------------------------------------------
[ "$(id -u)" -eq 0 ] || die "must run as root"
# Root is not enough: the admin binaries must also be reachable (#139). This
# sits beside the root check so identity and capability are asserted together,
# and BEFORE any prompt or download — a token typed for a doomed run is waste.
require_admin_bins useradd
if [ -r /etc/os-release ]; then
# Sourced in a subshell: os-release defines VERSION (e.g. "13 (trixie)"),
# which would clobber this script's $VERSION.

View file

@ -11,6 +11,8 @@ set -euo pipefail
HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
# shellcheck source=SCRIPTDIR/lib/users-config.sh
. "$HERE/lib/users-config.sh"
# shellcheck source=SCRIPTDIR/lib/admin-path.sh
. "$HERE/lib/admin-path.sh"
log() { printf 'rig-users: %s\n' "$*"; }
warn() { printf 'rig-users: WARNING: %s\n' "$*" >&2; }
@ -136,6 +138,10 @@ done <<< "$PARSED"
# --- guards ------------------------------------------------------------------
[ "$(id -u)" -eq 0 ] || die "must run as root"
# Root is not enough: the admin binaries must also be reachable (#139). This
# sits beside the root check so identity and capability are asserted together,
# and BEFORE any prompt or download — a token typed for a doomed run is waste.
require_admin_bins useradd usermod groupadd
# Identity management gates its INVOKER, not just its uid: %rig's sudoers rule
# is binary-scoped but not argument-scoped, so without this gate a rig-role
@ -195,6 +201,26 @@ if [ "$NEED_SUDO" -eq 1 ] && ! command -v sudo >/dev/null 2>&1; then
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq sudo
CHANGED=1
fi
# visudo is checked HERE and not beside the root check, because until the block
# above has run there is a legitimate reason for it to be absent: sudo is not
# installed yet, and apply is what installs it. Above, a missing visudo would
# be indistinguishable from that, so the refusal would fire on a healthy box.
#
# Below, it is unambiguous. sudo is present, so `visudo` missing means only one
# thing: /usr/sbin is off PATH. And it MUST refuse here rather than be left to
# the sudoers block further down, because that block asks `command -v visudo`
# and treats false as "no sudo on the box means no role needed it" — which on a
# PATH-shorn root is FALSE TWICE. A role does need it, sudo is installed, and
# apply would finish reporting success having silently never written the
# sudoers drop-in: the users get their roles and not the escalation the roles
# are FOR. That is the failure this whole issue is about (a wrong effective
# state reported as success, #12), in its quietest form — the other three sites
# at least crash. Refusing before the first mutation is what keeps it loud.
#
# It sits before `groupadd` below, so nothing has been converged when it fires.
if [ "$NEED_SUDO" -eq 1 ]; then
require_admin_bins visudo
fi
# --- groups ------------------------------------------------------------------
groupadd -f rig-admin

View file

@ -3334,6 +3334,121 @@ check "ci-box: its install.sh does not register" 1 "" \
check "ci-box: bootstrap-tenant does not read the staging dir" 1 "" \
grep -q 'docs/templates' "$ROOT/commands/bootstrap-tenant.sh"
# --- admin binaries must be reachable, not just root (#139) ------------------
# Being root and being able to FIND the admin binaries are different facts, and
# rig asserted only the first. `su` without `-`, sudo with a sanitised
# secure_path, and several container images all give a root shell whose PATH
# carries no /usr/sbin — where useradd lives. Reported from a real ci-box:
#
# root@ci-forgejo-box:/home/dev# rig forgejo-runner install --instance …
# forgejo runner registration token:
# …/forgejo-runner-install.sh: line 250: useradd: command not found
#
# Note where it died: AFTER reading a registration token off the operator's
# terminal. A secret typed for a run that could never succeed is the avoidable
# half of the bug, so the refusal has to come before the prompt.
#
# The root check fires first and correctly, so these stub `id -u` to 0 — the
# idiom the bootstrap --undo block above already uses — to reach the preflight.
ADMPATH_DIR="$(mktemp -d)"
mkdir -p "$ADMPATH_DIR/bin"
# shellcheck disable=SC2016 # the body is shell source being written, not expanded
printf '#!/usr/bin/env bash\nif [ "${1:-}" = -u ]; then printf "0\\n"; else exec /usr/bin/id "$@"; fi\n' \
> "$ADMPATH_DIR/bin/id"
chmod +x "$ADMPATH_DIR/bin/id"
# A PATH with the stub and the ordinary bindirs, but deliberately no /usr/sbin.
SBINLESS="$ADMPATH_DIR/bin:/usr/local/bin:/usr/bin:/bin"
adm_run() { env PATH="$SBINLESS" "$@" 2>&1; }
adm_prompted() { # did it read a token before refusing?
adm_run "$@" | grep -qi 'registration token:'
}
check "preflight: forgejo-runner install refuses a PATH with no /usr/sbin" 1 "useradd" \
adm_run "$ROOT/commands/forgejo-runner-install.sh" --instance https://f.example.com
check "preflight: …and names PATH as the cause, not just the missing binary" 1 "PATH" \
adm_run "$ROOT/commands/forgejo-runner-install.sh" --instance https://f.example.com
check "preflight: …and refuses BEFORE prompting for a token" 1 "" \
adm_prompted "$ROOT/commands/forgejo-runner-install.sh" --instance https://f.example.com
check "preflight: the GitHub runner installer refuses too" 1 "useradd" \
adm_run "$ROOT/commands/runner-install.sh" --repo o/r
check "preflight: users apply refuses before it converges anything" 1 "useradd" \
adm_run "$ROOT/commands/users-apply.sh" --file /dev/null
# A sbin-less PATH loses ALL of /usr/sbin at once, so the checks above can only
# ever prove the FIRST binary is named — `useradd` wins every race and would
# hide a preflight that forgot the others. These fixtures resolve the earlier
# binaries and withhold exactly one, which is the only way to show the sweep
# covers what each command actually calls (the #75 lesson: a sweep that misses
# one call site is how the next bug gets in).
#
# The withheld binary is real: these are PATHs, not stubs of the tools
# themselves — a stub that silently succeeded would let convergence run on.
admstub() { # admstub <dir> <bin>... — a bindir resolving id(0) plus <bin>...
local d="$1"; shift
mkdir -p "$d"
# shellcheck disable=SC2016 # shell source being written, not expanded
printf '#!/usr/bin/env bash\nif [ "${1:-}" = -u ]; then printf "0\\n"; else exec /usr/bin/id "$@"; fi\n' > "$d/id"
chmod +x "$d/id"
local b
for b in "$@"; do printf '#!/usr/bin/env bash\nexit 0\n' > "$d/$b"; chmod +x "$d/$b"; done
}
adm_with() { # adm_with <bindir> <cmd>...
local d="$1"; shift
env PATH="$d:/usr/local/bin:/usr/bin:/bin" "$@" 2>&1
}
# adm_saw <bindir> <pattern> <cmd>... — exit 0 if the run printed <pattern>.
# Used with `check ... 1 ""` to assert a thing did NOT happen (a token prompt,
# a group creation), the same shape as adm_prompted above.
adm_saw() {
local d="$1" pat="$2"; shift 2
adm_with "$d" "$@" | grep -qi -- "$pat"
}
# useradd resolves, usermod does not — the forgejo installer calls both, and
# only reaches usermod after the token has been spent.
admstub "$ADMPATH_DIR/no-usermod" useradd
check "preflight: forgejo-runner install names usermod when only useradd resolves" 1 "usermod" \
adm_with "$ADMPATH_DIR/no-usermod" "$ROOT/commands/forgejo-runner-install.sh" --instance https://f.example.com
check "preflight: …and still refuses before the token prompt" 1 "" \
adm_saw "$ADMPATH_DIR/no-usermod" 'registration token:' \
"$ROOT/commands/forgejo-runner-install.sh" --instance https://f.example.com
# users apply calls groupadd unconditionally, two lines into its convergence.
admstub "$ADMPATH_DIR/no-groupadd" useradd usermod
check "preflight: users apply names groupadd when useradd and usermod resolve" 1 "groupadd" \
adm_with "$ADMPATH_DIR/no-groupadd" "$ROOT/commands/users-apply.sh" --file /dev/null
# visudo is the quiet one. With sudo INSTALLED and /usr/sbin off PATH, the
# sudoers block reads `command -v visudo` as "no sudo on the box" and skips the
# drop-in — apply then reports success having granted roles without the
# escalation those roles exist for. So this fixture needs a role that wants
# sudo, and asserts the refusal by name rather than a silent success.
ADM_USERS="$ADMPATH_DIR/users"
printf '%s\n' 'dan admin ssh-ed25519 AAAAC3fixture dan@laptop' > "$ADM_USERS"
admstub "$ADMPATH_DIR/no-visudo" useradd usermod groupadd
check "preflight: users apply refuses a sudo-needing role when visudo is unreachable" 1 "visudo" \
adm_with "$ADMPATH_DIR/no-visudo" "$ROOT/commands/users-apply.sh" --file "$ADM_USERS" --yes
# …and the refusal must land BEFORE the groups are converged, or the machine is
# already half-changed when the operator reads it.
check "preflight: …before any group is created" 1 "" \
adm_saw "$ADMPATH_DIR/no-visudo" 'rig-admin' \
"$ROOT/commands/users-apply.sh" --file "$ADM_USERS" --yes
# A users file needing no sudo must NOT be refused for a missing visudo: the
# guard has to be as narrow as the need, or it refuses healthy boxes.
printf '%s\n' 'maria ops ssh-ed25519 AAAAC3fixture maria@mac' > "$ADMPATH_DIR/users-nosudo"
check "preflight: …but a file with no sudo-backed role is not refused for visudo" 1 "" \
adm_saw "$ADMPATH_DIR/no-visudo" 'visudo' \
"$ROOT/commands/users-apply.sh" --file "$ADMPATH_DIR/users-nosudo" --yes
# The fourth call site (#139 review): bootstrap-tenant adds the tenant user to
# the docker group AFTER installing docker and node, so an unguarded PATH fails
# it mid-convergence on a machine it has already changed.
# staging-box because it is the one tenant role defined in rig's own tree: the
# others resolve through the template registry, and a preflight test must not
# depend on the network to reach the guard it is testing.
admstub "$ADMPATH_DIR/no-any"
check "preflight: bootstrap-tenant refuses a sbin-less PATH before it converges" 1 "usermod" \
adm_with "$ADMPATH_DIR/no-any" "$ROOT/commands/bootstrap-tenant.sh" staging-box
rm -rf "$ADMPATH_DIR"
# --- rig forgejo-runner (#109) ----------------------------------------------
FR="$ROOT/commands/forgejo-runner-install.sh"
check "forgejo-runner: bare subcommand shows usage, exit 2" 2 "usage:" "$ROOT/bin/rig" forgejo-runner