forked from heavy-duty/rig
Operators become a declared fact, not an accumulation of adduser runs: a line-based, bash-parseable users file (no YAML, no jq — a rig box has neither) names each user, their roles, and their keys, and apply converges the box to exactly that. Roles map to groups (admin→rig-admin with full NOPASSWD sudo, rig→rig sudo for the rig binary only, box→incus with no sudo — box's setup-host owns Incus, rig only asserts the group). Every password stays locked always; the SSH key at the door is the authentication. A user dropped from the file is found via the /etc/rig/users ledger and locked, never deleted — deleting frees the uid and rots attribution. The sudoers drop-in lands only after visudo -c passes, because a bad file under sudoers.d takes down all of sudo. Class never gates apply (#26: a shared root login is unattributable, so operators belong on every class); the marker only colors what root SSH does next. The whole file is validated in one pass before the root check, every error named with its line, so refusals are provable in the non-root harness through the sourced parser. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
236 lines
9.1 KiB
Bash
Executable file
236 lines
9.1 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
# rig users apply — converge named operator accounts from a declarative users
|
|
# file, on every class. Humans always enter as themselves and elevate via
|
|
# sudo: a shared root login is unattributable, so operators belong on servers
|
|
# too — class never gates this command, it only decides root SSH's fate AFTER
|
|
# users exist (close-root on human, kept as the control plane's automation
|
|
# door on server). Convergent: a second identical run changes nothing and
|
|
# says so.
|
|
set -euo pipefail
|
|
|
|
HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
|
|
# shellcheck source=SCRIPTDIR/lib/users-config.sh
|
|
. "$HERE/lib/users-config.sh"
|
|
|
|
log() { printf 'rig-users: %s\n' "$*"; }
|
|
warn() { printf 'rig-users: WARNING: %s\n' "$*" >&2; }
|
|
die() { printf 'rig-users: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; }
|
|
|
|
usage() {
|
|
cat <<'EOF'
|
|
usage: rig users apply --file <path>
|
|
|
|
--file <path> users file (required; '-' reads it from stdin)
|
|
|
|
The file is line-based and bash-parseable on purpose — a rig box has no YAML
|
|
parser and no jq, and gets neither for this. Whitespace-separated: user,
|
|
comma-joined roles, then the SSH public key (the rest of the line). '#'
|
|
comments and blank lines are fine. Repeated username lines add authorized
|
|
keys; the roles must be identical on every line of one user.
|
|
|
|
# user roles ssh public key
|
|
dan admin,box ssh-ed25519 AAAA... dan@laptop
|
|
maria rig,box ssh-ed25519 AAAA... maria@mac
|
|
|
|
roles:
|
|
admin group rig-admin — full NOPASSWD sudo
|
|
rig group rig — NOPASSWD sudo for /usr/local/bin/rig only
|
|
box group incus — Incus restricted tier, no sudo (box's setup-host
|
|
owns the Incus install; rig only asserts it)
|
|
|
|
All passwords stay locked, always — the SSH key at the door is the
|
|
authentication, and NOPASSWD sudo does not weaken it. Convergent: membership
|
|
in the three rig-managed groups is made exact (other groups are never
|
|
touched), authorized_keys becomes exactly the file's keys, and a user dropped
|
|
from the file is locked and stripped of the rig groups — home kept, never
|
|
deleted. Run as root.
|
|
EOF
|
|
}
|
|
|
|
# --- args (validated before the root check, so errors are testable) ---------
|
|
FILE=""
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--file)
|
|
[ $# -ge 2 ] || die "--file needs a value" 2
|
|
FILE="$2"; shift 2 ;;
|
|
-h|--help) usage; exit 0 ;;
|
|
*) die "unknown flag: $1" 2 ;;
|
|
esac
|
|
done
|
|
[ -n "$FILE" ] || die "--file <path> is required" 2
|
|
|
|
# stdin is read ONCE into a temp: the file is parsed for validation and then
|
|
# walked again to converge, and a pipe only plays once.
|
|
if [ "$FILE" = "-" ]; then
|
|
STDIN_TMP="$(mktemp)"
|
|
cat > "$STDIN_TMP"
|
|
FILE="$STDIN_TMP"
|
|
fi
|
|
[ -r "$FILE" ] || die "cannot read users file: $FILE" 2
|
|
|
|
# File parsing is argument validation: every error in the file is reported in
|
|
# one pass, exit 2, still before the root check.
|
|
PARSED="$(parse_users_file "$FILE")" \
|
|
|| die "invalid users file: $FILE — every error is listed above; nothing was changed" 2
|
|
|
|
declare -A USER_ROLES=() USER_KEYS=()
|
|
USERS=()
|
|
NEED_SUDO=0
|
|
NEED_INCUS=0
|
|
while IFS='|' read -r u r k; do
|
|
[ -n "$u" ] || continue
|
|
if [ -z "${USER_ROLES[$u]:-}" ]; then
|
|
USERS+=("$u")
|
|
USER_ROLES[$u]="$r"
|
|
fi
|
|
USER_KEYS[$u]="${USER_KEYS[$u]:-}$k"$'\n'
|
|
case ",$r," in *,admin,*|*,rig,*) NEED_SUDO=1 ;; esac
|
|
case ",$r," in *,box,*) NEED_INCUS=1 ;; esac
|
|
done <<< "$PARSED"
|
|
|
|
# --- guards ------------------------------------------------------------------
|
|
[ "$(id -u)" -eq 0 ] || die "must run as root"
|
|
|
|
# Class is a note, never a refusal: #26's call is that operators belong on
|
|
# EVERY class — what differs is root SSH's fate once they exist.
|
|
case "$(read_role_marker /etc/rig/role)" in
|
|
*class=server*) log "class=server: root SSH stays — it is the control plane's automation door" ;;
|
|
*class=human*) log "class=human: once your admin key works, 'rig users close-root' shuts the root door" ;;
|
|
"") warn "no /etc/rig/role marker — re-run rig bootstrap so this box knows what it is" ;;
|
|
esac
|
|
|
|
CHANGED=0
|
|
|
|
# --- sudo (only when some role actually grants through it) -------------------
|
|
if [ "$NEED_SUDO" -eq 1 ] && ! command -v sudo >/dev/null 2>&1; then
|
|
log "installing sudo (admin/rig grants go through it)"
|
|
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq sudo
|
|
CHANGED=1
|
|
fi
|
|
|
|
# --- groups ------------------------------------------------------------------
|
|
groupadd -f rig-admin
|
|
groupadd -f rig
|
|
# rig NEVER installs Incus: box's setup-host owns the daemon and its group. An
|
|
# absent incus group means that never ran — refuse with the pointer rather
|
|
# than conjure a group the (nonexistent) daemon would never consult.
|
|
if [ "$NEED_INCUS" -eq 1 ] && ! getent group incus >/dev/null; then
|
|
die "a user carries role box but group incus is absent — install the box CLI and run 'box setup-host' first; rig never installs Incus"
|
|
fi
|
|
|
|
in_group() { id -nG "$1" 2>/dev/null | tr ' ' '\n' | grep -qx "$2"; }
|
|
|
|
# --- converge each user ------------------------------------------------------
|
|
for u in "${USERS[@]}"; do
|
|
if ! id -u "$u" >/dev/null 2>&1; then
|
|
useradd -m -s /bin/bash "$u"
|
|
log "created user $u"
|
|
CHANGED=1
|
|
fi
|
|
# Locked always, created or found: no password ever exists to guess or
|
|
# rotate — the SSH key at the door is the authentication. Idempotent.
|
|
usermod -L "$u"
|
|
|
|
# Membership in the three rig-managed groups is made EXACT — added and
|
|
# removed to match the file. Other groups are never touched: they are not
|
|
# rig's to converge.
|
|
roles="${USER_ROLES[$u]}"
|
|
want=""
|
|
case ",$roles," in *,admin,*) want="$want rig-admin" ;; esac
|
|
case ",$roles," in *,rig,*) want="$want rig" ;; esac
|
|
case ",$roles," in *,box,*) want="$want incus" ;; esac
|
|
for g in rig-admin rig incus; do
|
|
case " $want " in
|
|
*" $g "*)
|
|
if ! in_group "$u" "$g"; then
|
|
usermod -aG "$g" "$u"
|
|
log "added $u to $g"
|
|
CHANGED=1
|
|
fi ;;
|
|
*)
|
|
if in_group "$u" "$g"; then
|
|
gpasswd -d "$u" "$g" >/dev/null
|
|
log "removed $u from $g"
|
|
CHANGED=1
|
|
fi ;;
|
|
esac
|
|
done
|
|
|
|
# authorized_keys becomes exactly the file's keys — cmp-guarded like every
|
|
# file rig converges, so an unchanged file is a clean no-op.
|
|
home="$(getent passwd "$u" | cut -d: -f6)"
|
|
ugroup="$(id -gn "$u")"
|
|
AK_TMP="$(mktemp)"
|
|
printf '%s' "${USER_KEYS[$u]}" > "$AK_TMP"
|
|
if ! cmp -s "$AK_TMP" "$home/.ssh/authorized_keys" 2>/dev/null; then
|
|
mkdir -p "$home/.ssh"
|
|
chmod 0700 "$home/.ssh"
|
|
chown "$u:$ugroup" "$home/.ssh"
|
|
install -m 0600 -o "$u" -g "$ugroup" "$AK_TMP" "$home/.ssh/authorized_keys"
|
|
log "authorized_keys for $u: $(grep -c . "$AK_TMP") key(s)"
|
|
CHANGED=1
|
|
fi
|
|
rm -f "$AK_TMP"
|
|
done
|
|
|
|
# --- previously managed users no longer in the file --------------------------
|
|
# The ledger is what lets a REMOVED user be found at all. Locked, not deleted:
|
|
# deleting frees the uid for reuse and orphans file ownership — attribution
|
|
# would rot. Home stays for the same reason.
|
|
LEDGER=/etc/rig/users
|
|
if [ -r "$LEDGER" ]; then
|
|
while IFS= read -r prev; do
|
|
[ -n "$prev" ] || continue
|
|
case " ${USERS[*]:-} " in *" $prev "*) continue ;; esac
|
|
id -u "$prev" >/dev/null 2>&1 || continue
|
|
usermod -L "$prev"
|
|
for g in rig-admin rig incus; do
|
|
if in_group "$prev" "$g"; then gpasswd -d "$prev" "$g" >/dev/null; fi
|
|
done
|
|
warn "$prev is no longer in the file: locked and stripped of the rig groups (home kept — rig never deletes a user)"
|
|
CHANGED=1
|
|
done < "$LEDGER"
|
|
fi
|
|
LEDGER_TMP="$(mktemp)"
|
|
if [ "${#USERS[@]}" -gt 0 ]; then printf '%s\n' "${USERS[@]}" > "$LEDGER_TMP"; fi
|
|
if ! cmp -s "$LEDGER_TMP" "$LEDGER" 2>/dev/null; then
|
|
mkdir -p /etc/rig
|
|
install -m 0644 "$LEDGER_TMP" "$LEDGER"
|
|
CHANGED=1
|
|
fi
|
|
rm -f "$LEDGER_TMP"
|
|
|
|
# --- sudoers -----------------------------------------------------------------
|
|
# Both group rules ship in one drop-in whether or not both roles are in use:
|
|
# the groups exist and the rules are inert without members. visudo gates the
|
|
# install because a bad file under /etc/sudoers.d can take down ALL of sudo —
|
|
# locking every admin out of the very escalation path apply just granted.
|
|
SUDOERS_TMP="$(mktemp)"
|
|
cat > "$SUDOERS_TMP" <<'EOF'
|
|
# Managed by `rig users apply` — do not edit; the next apply converges it.
|
|
%rig-admin ALL=(ALL:ALL) NOPASSWD: ALL
|
|
%rig ALL=(root) NOPASSWD: /usr/local/bin/rig
|
|
EOF
|
|
if command -v visudo >/dev/null 2>&1; then
|
|
visudo -c -f "$SUDOERS_TMP" >/dev/null \
|
|
|| die "sudoers candidate failed validation — /etc/sudoers.d untouched; candidate kept at $SUDOERS_TMP for inspection"
|
|
if ! cmp -s "$SUDOERS_TMP" /etc/sudoers.d/rig-roles 2>/dev/null; then
|
|
install -m 0440 "$SUDOERS_TMP" /etc/sudoers.d/rig-roles
|
|
log "sudoers role rules installed (/etc/sudoers.d/rig-roles)"
|
|
CHANGED=1
|
|
fi
|
|
rm -f "$SUDOERS_TMP"
|
|
else
|
|
# No sudo on the box means no role needed it (the install above would have
|
|
# run otherwise): rules for a binary that is not there can wait for the
|
|
# apply that brings a sudo-bearing role.
|
|
rm -f "$SUDOERS_TMP"
|
|
log "sudo not installed and no role needs it; skipping the sudoers drop-in"
|
|
fi
|
|
|
|
if [ "$CHANGED" -eq 0 ]; then
|
|
log "already converged; no changes"
|
|
else
|
|
log "converged ${#USERS[@]} user(s)"
|
|
fi
|