diff --git a/bin/rig b/bin/rig index 3dc2103..1fe8f95 100755 --- a/bin/rig +++ b/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 + 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 diff --git a/commands/lib/users-config.sh b/commands/lib/users-config.sh new file mode 100644 index 0000000..a468758 --- /dev/null +++ b/commands/lib/users-config.sh @@ -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 +# +# 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 — 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" +} diff --git a/commands/users-apply.sh b/commands/users-apply.sh new file mode 100755 index 0000000..36977d2 --- /dev/null +++ b/commands/users-apply.sh @@ -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 + + --file 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 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 diff --git a/commands/users-status.sh b/commands/users-status.sh new file mode 100755 index 0000000..8234bab --- /dev/null +++ b/commands/users-status.sh @@ -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" diff --git a/test/cli.sh b/test/cli.sh index a6ae065..143b4b0 100644 --- a/test/cli.sh +++ b/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 — 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.