5 changed files with 490 additions and 0 deletions
34
bin/rig
34
bin/rig
|
|
@ -42,6 +42,18 @@ commands:
|
|||
re-register, reusing the binary already on the box. Needs a removal
|
||||
token for the old repo and a registration token for the new one.
|
||||
Run as root.
|
||||
users apply --file <path>
|
||||
Converge named operator accounts from a declarative users file, on
|
||||
every class: groups by role (admin/rig/box), passwords locked always,
|
||||
authorized_keys made exact, visudo-gated sudoers rules. Users dropped
|
||||
from the file are locked, never deleted. '-' reads stdin. Run as root.
|
||||
users status
|
||||
Roles (derived from actual group membership), key counts and lock
|
||||
state for the rig-managed users. Reads the box only. Run as root.
|
||||
users close-root
|
||||
Shut root SSH on a class=human box once an admin key works. Refuses
|
||||
on class=server — root there is the control plane's automation door —
|
||||
and while no admin holds a key. Run as root.
|
||||
|
||||
install/upgrade:
|
||||
curl -fsSL https://raw.githubusercontent.com/heavy-duty/rig/main/install.sh | bash
|
||||
|
|
@ -103,6 +115,28 @@ case "$cmd" in
|
|||
;;
|
||||
esac
|
||||
;;
|
||||
users)
|
||||
shift
|
||||
sub="${1:-}"
|
||||
case "$sub" in
|
||||
apply)
|
||||
shift
|
||||
exec "$ROOT/commands/users-apply.sh" "$@"
|
||||
;;
|
||||
status)
|
||||
shift
|
||||
exec "$ROOT/commands/users-status.sh" "$@"
|
||||
;;
|
||||
close-root)
|
||||
shift
|
||||
exec "$ROOT/commands/users-close-root.sh" "$@"
|
||||
;;
|
||||
*)
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
-h|--help|help)
|
||||
usage
|
||||
exit 0
|
||||
|
|
|
|||
86
commands/lib/users-config.sh
Normal file
86
commands/lib/users-config.sh
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
#!/usr/bin/env bash
|
||||
# Shared parsing for the rig users family. Sourced by the users-* commands and
|
||||
# by the test harness against fixture files; never executed on its own.
|
||||
|
||||
# The users file is line-based and whitespace-separated on purpose: a
|
||||
# rig-bootstrapped box has no YAML parser and no jq, and `read` parses this
|
||||
# shape for free — same jq-free reason runner-config.sh greps JSON. One line
|
||||
# per key:
|
||||
#
|
||||
# # user roles ssh public key
|
||||
# dan admin,box ssh-ed25519 AAAA... dan@laptop
|
||||
#
|
||||
# Repeated username lines are additional authorized keys; the roles field must
|
||||
# be IDENTICAL on each — a repeated line means "another key", never a quiet
|
||||
# role edit hiding mid-file. '#' comments and blank lines are skipped.
|
||||
|
||||
# parse_users_file <path>
|
||||
#
|
||||
# Emits one normalized 'user|roles|key' line per key line on stdout. On ANY
|
||||
# validation error: EVERY error goes to stderr, each with its line number, no
|
||||
# stdout, return 1. All errors in one pass because a bad file should cost one
|
||||
# fix cycle, not one round-trip per line.
|
||||
#
|
||||
# Refusals: unknown role (the valid set is named), differing roles across one
|
||||
# user's lines, root as username (root's keys are class policy's business, not
|
||||
# this file's), malformed line (fewer than 3 fields, or a key field that does
|
||||
# not start with an SSH key type), duplicate identical key line.
|
||||
parse_users_file() {
|
||||
local path="$1"
|
||||
local -a errs=() out=() rlist=()
|
||||
local -A first_roles=() seen=()
|
||||
local line u r k role ok n=0
|
||||
while IFS= read -r line || [ -n "$line" ]; do
|
||||
n=$((n + 1))
|
||||
if [[ "$line" =~ ^[[:space:]]*(#|$) ]]; then continue; fi
|
||||
read -r u r k <<< "$line"
|
||||
if [ -z "${k:-}" ]; then
|
||||
errs+=("line $n: malformed — expected 'user roles ssh-public-key' (3+ whitespace-separated fields)")
|
||||
continue
|
||||
fi
|
||||
case "$k" in
|
||||
ssh-*|ecdsa-*|sk-ssh-*|sk-ecdsa-*) ;;
|
||||
*)
|
||||
errs+=("line $n: malformed — key field must start with an SSH key type (ssh-..., ecdsa-...)")
|
||||
continue ;;
|
||||
esac
|
||||
if [ "$u" = "root" ]; then
|
||||
errs+=("line $n: 'root' is not a rig-managed user — this file names operators; root SSH's fate is class policy")
|
||||
continue
|
||||
fi
|
||||
ok=1
|
||||
IFS=',' read -ra rlist <<< "$r"
|
||||
for role in "${rlist[@]}"; do
|
||||
case "$role" in
|
||||
admin|rig|box) ;;
|
||||
*) errs+=("line $n: unknown role '$role' for $u (valid roles: admin rig box)"); ok=0 ;;
|
||||
esac
|
||||
done
|
||||
if [ -n "${first_roles[$u]:-}" ] && [ "${first_roles[$u]}" != "$r" ]; then
|
||||
errs+=("line $n: $u has roles '$r' here but '${first_roles[$u]}' earlier — repeated lines add keys, roles must be identical")
|
||||
ok=0
|
||||
fi
|
||||
if [ -z "${first_roles[$u]:-}" ]; then first_roles[$u]="$r"; fi
|
||||
if [ -n "${seen[$u|$k]:-}" ]; then
|
||||
errs+=("line $n: duplicate key line for $u (same key already on line ${seen[$u|$k]})")
|
||||
continue
|
||||
fi
|
||||
seen[$u|$k]="$n"
|
||||
if [ "$ok" -eq 1 ]; then out+=("$u|$r|$k"); fi
|
||||
done < "$path"
|
||||
if [ "${#errs[@]}" -gt 0 ]; then
|
||||
printf '%s\n' "${errs[@]}" >&2
|
||||
return 1
|
||||
fi
|
||||
if [ "${#out[@]}" -gt 0 ]; then printf '%s\n' "${out[@]}"; fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# read_role_marker <path> — the marker line bootstrap wrote
|
||||
# (`role=... class=... host=... join=...`), or nothing when absent. NO policy
|
||||
# here: what an absent marker or a given class MEANS is each caller's call
|
||||
# (apply notes it, close-root refuses on it) — the lib only reads.
|
||||
read_role_marker() {
|
||||
[ -r "$1" ] || return 0
|
||||
head -n1 "$1"
|
||||
}
|
||||
236
commands/users-apply.sh
Executable file
236
commands/users-apply.sh
Executable file
|
|
@ -0,0 +1,236 @@
|
|||
#!/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
|
||||
65
commands/users-status.sh
Executable file
65
commands/users-status.sh
Executable file
|
|
@ -0,0 +1,65 @@
|
|||
#!/usr/bin/env bash
|
||||
# rig users status — what this box's operator accounts actually are, read from
|
||||
# the machine itself: roles derived from REAL group membership (not the
|
||||
# ledger's memory of an apply), key counts from authorized_keys, lock state
|
||||
# from shadow. Reads only — no network, no writes.
|
||||
set -euo pipefail
|
||||
|
||||
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 status
|
||||
|
||||
Per rig-managed user (the /etc/rig/users ledger): roles derived from the
|
||||
groups the user is ACTUALLY in (rig-admin -> admin, rig -> rig, incus -> box),
|
||||
the authorized_keys count, and whether the account is locked or active.
|
||||
Reads the box only — no network, no writes. Run as root (shadow is read).
|
||||
EOF
|
||||
}
|
||||
|
||||
# --- args (validated before the root check, so errors are testable) ---------
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) die "unknown flag: $1" 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[ "$(id -u)" -eq 0 ] || die "must run as root"
|
||||
|
||||
LEDGER=/etc/rig/users
|
||||
if [ ! -r "$LEDGER" ]; then
|
||||
log "no rig-managed users (no $LEDGER yet — rig users apply creates it)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
while IFS= read -r u; do
|
||||
[ -n "$u" ] || continue
|
||||
if ! id -u "$u" >/dev/null 2>&1; then
|
||||
# In the ledger but off the box: someone deleted by hand what rig only
|
||||
# ever locks. Say so rather than crash or silently skip.
|
||||
warn "$u: in the ledger but not on the box (rig never deletes — removed by hand?)"
|
||||
continue
|
||||
fi
|
||||
groups=" $(id -nG "$u") "
|
||||
roles=""
|
||||
case "$groups" in *" rig-admin "*) roles="admin" ;; esac
|
||||
case "$groups" in *" rig "*) roles="${roles:+$roles,}rig" ;; esac
|
||||
case "$groups" in *" incus "*) roles="${roles:+$roles,}box" ;; esac
|
||||
[ -n "$roles" ] || roles="none"
|
||||
home="$(getent passwd "$u" | cut -d: -f6)"
|
||||
keys=0
|
||||
if [ -r "$home/.ssh/authorized_keys" ]; then
|
||||
keys="$(grep -c . "$home/.ssh/authorized_keys" || true)"
|
||||
fi
|
||||
# Field 2 of `passwd -S` is the lock flag; locked is apply's resting state
|
||||
# for a user dropped from the file, so it is the fact worth surfacing.
|
||||
state=active
|
||||
case "$(passwd -S "$u" 2>/dev/null | awk '{print $2}')" in
|
||||
L|LK) state=locked ;;
|
||||
esac
|
||||
log "$u roles=$roles keys=$keys $state"
|
||||
done < "$LEDGER"
|
||||
69
test/cli.sh
69
test/cli.sh
|
|
@ -266,6 +266,75 @@ else
|
|||
echo "skip: runner status/remove/repoint non-root refusals (running as root)"
|
||||
fi
|
||||
|
||||
check "bare users shows usage, exit 2" 2 "usage:" "$ROOT/bin/rig" users
|
||||
check "users: bad subcommand exits 2" 2 "usage:" "$ROOT/bin/rig" users frobnicate
|
||||
|
||||
check "users apply: --help exits 0" 0 "usage:" "$ROOT/commands/users-apply.sh" --help
|
||||
check "users apply: --file required" 2 "--file" "$ROOT/commands/users-apply.sh"
|
||||
check "users apply: --file needs value" 2 "needs a value" "$ROOT/commands/users-apply.sh" --file
|
||||
check "users apply: missing file exits 2" 2 "cannot read" "$ROOT/commands/users-apply.sh" --file /nonexistent/users
|
||||
check "users apply: unknown flag exits 2" 2 "unknown flag" "$ROOT/commands/users-apply.sh" --nope
|
||||
check "users status: --help exits 0" 0 "usage:" "$ROOT/commands/users-status.sh" --help
|
||||
|
||||
# --- users file refusal matrix, through the sourced parser -------------------
|
||||
# Reaching the parser via the CLI stops at the root check; it is pure and
|
||||
# sourceable on purpose (repo precedent: assert_runner_repo, json_string_array),
|
||||
# so the refusals are proven here against fixtures, non-root and network-free.
|
||||
parse() { # parse <file> — the users-file parser, exactly as apply runs it
|
||||
bash -c 'set -euo pipefail
|
||||
. "$1/commands/lib/users-config.sh"
|
||||
parse_users_file "$2"' _ "$ROOT" "$1"
|
||||
}
|
||||
FIX_OK="$(mktemp)" # two operators; dan carries a second key on a repeat line
|
||||
FIX_BAD="$(mktemp)" # rewritten per refusal below
|
||||
cat > "$FIX_OK" <<'USERS'
|
||||
# fleet operators
|
||||
dan admin,box ssh-ed25519 AAAAC3fixture dan@laptop
|
||||
dan admin,box ssh-ed25519 AAAAC3second dan@desk
|
||||
|
||||
maria rig ssh-ed25519 AAAAC3fixture maria@mac
|
||||
USERS
|
||||
printf '%s\n' 'maria ops ssh-ed25519 AAAA maria@mac' > "$FIX_BAD"
|
||||
check "users parser: unknown role names the valid set" 1 "valid roles: admin rig box" parse "$FIX_BAD"
|
||||
printf '%s\n' 'dan admin ssh-ed25519 AAAA a' 'dan admin,box ssh-ed25519 BBBB b' > "$FIX_BAD"
|
||||
check "users parser: differing roles across one user's lines" 1 "roles must be identical" parse "$FIX_BAD"
|
||||
printf '%s\n' 'root admin ssh-ed25519 AAAA r' > "$FIX_BAD"
|
||||
check "users parser: root is refused" 1 "not a rig-managed user" parse "$FIX_BAD"
|
||||
printf '%s\n' 'dan admin' > "$FIX_BAD"
|
||||
check "users parser: malformed line is refused" 1 "malformed" parse "$FIX_BAD"
|
||||
check "users parser: valid file emits dan (both keys' roles agree)" \
|
||||
0 "dan|admin,box|ssh-ed25519 AAAAC3second dan@desk" parse "$FIX_OK"
|
||||
check "users parser: valid file emits maria too" 0 "maria|rig|ssh-ed25519" parse "$FIX_OK"
|
||||
# ALL errors in ONE pass: a bad file costs one fix cycle, not one per error.
|
||||
# A single invocation, both messages asserted from its one stderr.
|
||||
printf '%s\n' 'root admin ssh-ed25519 AAAA r' 'maria ops ssh-ed25519 AAAA m' > "$FIX_BAD"
|
||||
MULTI_ERRS="$(mktemp)"
|
||||
parse "$FIX_BAD" 2> "$MULTI_ERRS"; multi_rc=$?
|
||||
check "users parser: multi-error file exits 1" 0 "" test "$multi_rc" -eq 1
|
||||
check "users parser: one run reports the root line" 0 "" grep -q "not a rig-managed user" "$MULTI_ERRS"
|
||||
check "users parser: same run reports the bad role" 0 "" grep -q "unknown role" "$MULTI_ERRS"
|
||||
rm -f "$MULTI_ERRS"
|
||||
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
# A VALID fixture proves the whole file-validation pass sits before the
|
||||
# root check — a parse failure here would exit 2, not 1.
|
||||
check "users apply: refuses non-root" 1 "must run as root" "$ROOT/commands/users-apply.sh" --file "$FIX_OK"
|
||||
check "users status: refuses non-root" 1 "must run as root" "$ROOT/commands/users-status.sh"
|
||||
else
|
||||
echo "skip: users non-root refusals (running as root)"
|
||||
fi
|
||||
rm -f "$FIX_OK" "$FIX_BAD"
|
||||
|
||||
# Validate-then-apply: `visudo -c` must pass before anything lands in
|
||||
# /etc/sudoers.d — a bad drop-in takes down ALL of sudo, locking every admin
|
||||
# out of the escalation path apply just granted. Assert the order in the file,
|
||||
# matching the calls rather than comments (repo precedent: the runner-install
|
||||
# repo-guard ordering check). Defaults fail closed.
|
||||
visudo_at="$(grep -n 'visudo -c' "$ROOT/commands/users-apply.sh" | head -n1 | cut -d: -f1)"
|
||||
sudoers_at="$(grep -nE 'install .*sudoers\.d/rig-roles' "$ROOT/commands/users-apply.sh" | head -n1 | cut -d: -f1)"
|
||||
check "users apply: visudo -c precedes the sudoers install" \
|
||||
0 "" test "${visudo_at:-999999}" -lt "${sudoers_at:-0}"
|
||||
|
||||
# The dump script ships to control-plane boxes as an embedded heredoc. A syntax
|
||||
# error in it would be invisible here and would first surface at 04:00 on a live
|
||||
# control plane. Extract it and syntax-check what actually gets written.
|
||||
|
|
|
|||
Loading…
Reference in a new issue