6 changed files with 288 additions and 64 deletions
63
README.md
63
README.md
|
|
@ -415,11 +415,24 @@ holds nothing secret anyway: usernames, roles, and *public* keys.
|
|||
| `rig` | NOPASSWD sudo for `/usr/local/bin/rig` only | `rig` |
|
||||
| `box` | Incus **restricted** tier, no sudo | `incus` |
|
||||
|
||||
`box` carries a refusal with it: rig never installs Incus — box's `setup-host`
|
||||
owns the daemon — so an absent `incus` group means that never ran, and apply
|
||||
dies pointing at `box setup-host` rather than conjure a group the
|
||||
(nonexistent) daemon would never consult. `incus-admin` is deliberately
|
||||
**not** a role: that group is host-root-equivalent, break-glass by hand only.
|
||||
**The honest limit of the `rig` role:** its sudo grant is binary-scoped, not
|
||||
argument-scoped — it trusts its holder with every rig verb *except* identity
|
||||
management. The `rig users` commands gate their **invoker**: run under sudo
|
||||
by anyone outside `rig-admin`, they refuse. Without that gate, `sudo rig
|
||||
users apply` against a file naming yourself admin would make the scoped grant
|
||||
silently root-equivalent through the very tool it scopes. Direct root — a
|
||||
bring-up shell, before any admin exists — proceeds.
|
||||
|
||||
`box` binds where VMs live, and a users file is fleet-wide — its box grants
|
||||
are not. rig never installs Incus — box's `setup-host` owns the daemon — so
|
||||
when the `incus` group is absent, the `host=` trait decides: on `host=yes`
|
||||
apply dies pointing at `box setup-host` (a VM host missing Incus is a real
|
||||
problem) rather than conjure a group the (nonexistent) daemon would never
|
||||
consult; on `host=no` the box role is **skipped with a warning** and
|
||||
everything else — admins included — still converges, because one box-role
|
||||
user somewhere in the fleet must not stop apply everywhere VMs don't live.
|
||||
`incus-admin` is deliberately **not** a role: that group is
|
||||
host-root-equivalent, break-glass by hand only.
|
||||
|
||||
**All passwords stay locked, always** — created or found. The SSH key at the
|
||||
door is the authentication, and NOPASSWD sudo does not weaken it: there was
|
||||
|
|
@ -427,14 +440,21 @@ never a password to guess or rotate.
|
|||
|
||||
Convergence is exact. Membership in the three rig-managed groups is made to
|
||||
match the file — added *and* removed — while every other group is left alone:
|
||||
not rig's to converge. `authorized_keys` becomes exactly the file's keys. A
|
||||
user dropped from the file is found via the `/etc/rig/users` ledger and
|
||||
**locked, never deleted** — deletion frees the uid for reuse and orphans file
|
||||
ownership, so attribution would rot; home stays for the same reason. And the
|
||||
sudoers rules land in `/etc/sudoers.d/rig-roles` only after `visudo -c`
|
||||
passes on the candidate — 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.
|
||||
not rig's to converge. `authorized_keys` becomes exactly the file's keys, and
|
||||
its ownership and mode (and `.ssh`'s) are converged on **every** run, not
|
||||
just when content changes — sshd's `StrictModes` treats them as
|
||||
load-bearing, so drifted perms are a broken login that "already converged"
|
||||
would lie about. A user dropped from the file is found via the
|
||||
`/etc/rig/users` ledger and **revoked, never deleted**: the account is
|
||||
expired — the switch PAM actually enforces; a locked password alone still
|
||||
lets a pubkey in under Debian's `UsePAM` — and `authorized_keys` is renamed
|
||||
to `authorized_keys.revoked-by-rig`. Access revoked, data kept: deletion
|
||||
frees the uid for reuse and orphans file ownership, so attribution would rot;
|
||||
home stays for the same reason, and re-adding the user to the file brings
|
||||
them back, fresh keys and all. And the sudoers rules land in
|
||||
`/etc/sudoers.d/rig-roles` only after `visudo -c` passes on the candidate — 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.
|
||||
|
||||
### `rig users status`
|
||||
|
||||
|
|
@ -444,8 +464,12 @@ rig users status
|
|||
|
||||
Read-only truth: per rig-managed user, the roles derived from the groups the
|
||||
user is **actually** in — not the ledger's memory of an apply — plus 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).
|
||||
`authorized_keys` count (`revoked` when only the `.revoked-by-rig` rename
|
||||
remains) and the user's state, **active** or **revoked**. The state is the
|
||||
ledger's word corroborated by the account's real expiry — the switch that
|
||||
actually revokes — and a mismatch is flagged loudly as drift: a box someone
|
||||
changed behind rig's back must never read as healthy. Reads the box only; no
|
||||
network, no writes. Run as root (shadow is read).
|
||||
|
||||
### `rig users close-root`
|
||||
|
||||
|
|
@ -459,8 +483,13 @@ must say `class=human` — an absent marker refuses (never shut the root door
|
|||
blind; re-run bootstrap so the box knows what it is), and `class=server`
|
||||
refuses with no `--force`, because root there is the control plane's
|
||||
automation identity and closing it severs fleet management. Then at least one
|
||||
`rig-admin` member must hold a non-empty `authorized_keys` — never close the
|
||||
only door.
|
||||
`rig-admin` member must hold a login sshd would plausibly **accept** — a
|
||||
non-empty `authorized_keys` alone proves a file, not a door: the gate checks
|
||||
the `StrictModes` shape (home, `.ssh`, and `authorized_keys` owned by the
|
||||
user and not group/world-writable), a real login shell, and an unexpired
|
||||
account, and its refusal names which check failed, per candidate. It proves
|
||||
the door *should* open, not that it does — which is why the separate-session
|
||||
verification below stays load-bearing. Never close the only door.
|
||||
|
||||
Before running it, prove the admin door in a **separate** session — `ssh
|
||||
<admin>@<box>` while this one stays open. Root SSH is being welded shut; the
|
||||
|
|
|
|||
|
|
@ -24,7 +24,9 @@
|
|||
# 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.
|
||||
# not start with an SSH key type), invalid username (the charset below —
|
||||
# '|' would corrupt this parser's own delimited stream, a leading '-' reads
|
||||
# as a useradd flag), duplicate identical key line.
|
||||
parse_users_file() {
|
||||
local path="$1"
|
||||
local -a errs=() out=() rlist=()
|
||||
|
|
@ -44,6 +46,14 @@ parse_users_file() {
|
|||
errs+=("line $n: malformed — key field must start with an SSH key type (ssh-..., ecdsa-...)")
|
||||
continue ;;
|
||||
esac
|
||||
# The username feeds this parser's own '|'-delimited stream and then
|
||||
# useradd: 'fo|o' silently becomes user 'fo' with garbage keys, and a
|
||||
# leading '-' reads as a useradd flag mid-convergence. One safe charset
|
||||
# refuses both by construction (and ':', which would corrupt passwd).
|
||||
if ! [[ "$u" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]]; then
|
||||
errs+=("line $n: invalid username '$u' — must match ^[a-z_][a-z0-9_-]{0,31}\$ (lowercase letter or '_' first, then lowercase, digits, '_', '-'; max 32)")
|
||||
continue
|
||||
fi
|
||||
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
|
||||
|
|
|
|||
|
|
@ -42,8 +42,12 @@ 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.
|
||||
from the file is REVOKED: account expired (which blocks SSH keys too, not
|
||||
just the password), authorized_keys renamed to authorized_keys.revoked-by-rig,
|
||||
rig groups stripped — home kept, nothing deleted, and re-adding the user
|
||||
brings them back. Run as root; under sudo, only rig-admin members may — the
|
||||
users family changes who holds root, so role rig's scoped sudo does not reach
|
||||
it.
|
||||
EOF
|
||||
}
|
||||
|
||||
|
|
@ -76,6 +80,7 @@ PARSED="$(parse_users_file "$FILE")" \
|
|||
|
||||
declare -A USER_ROLES=() USER_KEYS=()
|
||||
USERS=()
|
||||
BOX_USERS=()
|
||||
NEED_SUDO=0
|
||||
NEED_INCUS=0
|
||||
while IFS='|' read -r u r k; do
|
||||
|
|
@ -83,6 +88,7 @@ while IFS='|' read -r u r k; do
|
|||
if [ -z "${USER_ROLES[$u]:-}" ]; then
|
||||
USERS+=("$u")
|
||||
USER_ROLES[$u]="$r"
|
||||
case ",$r," in *,box,*) BOX_USERS+=("$u") ;; esac
|
||||
fi
|
||||
USER_KEYS[$u]="${USER_KEYS[$u]:-}$k"$'\n'
|
||||
case ",$r," in *,admin,*|*,rig,*) NEED_SUDO=1 ;; esac
|
||||
|
|
@ -92,9 +98,19 @@ done <<< "$PARSED"
|
|||
# --- guards ------------------------------------------------------------------
|
||||
[ "$(id -u)" -eq 0 ] || die "must run as root"
|
||||
|
||||
# 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
|
||||
# user could run `sudo rig users apply --file <me-as-admin>` — the scoped
|
||||
# grant silently root-equivalent through this very command. Direct root (no
|
||||
# SUDO_USER: bring-up, a root shell) proceeds.
|
||||
if [ -n "${SUDO_USER:-}" ] && [ "$SUDO_USER" != "root" ] \
|
||||
&& ! id -nG "$SUDO_USER" 2>/dev/null | tr ' ' '\n' | grep -qx rig-admin; then
|
||||
die "the users family changes who holds root — only rig-admin members (or root itself) may run it; role rig grants operational rig use, not identity management (invoker: $SUDO_USER)"
|
||||
fi
|
||||
|
||||
# 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
|
||||
case "$(read_role_marker "${RIG_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" ;;
|
||||
|
|
@ -113,10 +129,22 @@ fi
|
|||
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"
|
||||
# absent incus group means that never ran — but what that MEANS is the host=
|
||||
# trait's call. The box role binds where VMs live; a users file is fleet-wide,
|
||||
# its box grants are not. So on host=yes an absent group is a broken VM host
|
||||
# (refuse, point at setup-host), while on host=no it is simply not this box's
|
||||
# role to converge — skip it, never abort the admins the file also carries.
|
||||
INCUS_OK=0
|
||||
if getent group incus >/dev/null; then INCUS_OK=1; fi
|
||||
if [ "$NEED_INCUS" -eq 1 ] && [ "$INCUS_OK" -eq 0 ]; then
|
||||
case "$(read_role_marker "${RIG_ROLE_MARKER:-/etc/rig/role}")" in
|
||||
*host=yes*)
|
||||
die "a user carries role box and this box hosts VMs (host=yes) but group incus is absent — install the box CLI and run 'box setup-host' first; rig never installs Incus" ;;
|
||||
*host=no*)
|
||||
warn "box role skipped for ${BOX_USERS[*]}: this box does not host VMs (host=no); everything else converges" ;;
|
||||
*)
|
||||
warn "box role skipped for ${BOX_USERS[*]}: the role marker names no host= trait — re-run rig bootstrap so this box knows whether it hosts VMs" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
in_group() { id -nG "$1" 2>/dev/null | tr ' ' '\n' | grep -qx "$2"; }
|
||||
|
|
@ -129,8 +157,10 @@ for u in "${USERS[@]}"; do
|
|||
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"
|
||||
# rotate — the SSH key at the door is the authentication. The expiry is
|
||||
# cleared just as idempotently: revocation below IS an expiry date, so a
|
||||
# user dropped once and re-added comes back to life on this line.
|
||||
usermod -L -e '' "$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
|
||||
|
|
@ -139,7 +169,10 @@ for u in "${USERS[@]}"; do
|
|||
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
|
||||
# incus joins the wanted set only when the group exists (host=no boxes
|
||||
# skipped it above): converging membership in a conjured group would hand
|
||||
# the daemon's arrival an audience it never granted.
|
||||
case ",$roles," in *,box,*) if [ "$INCUS_OK" -eq 1 ]; then want="$want incus"; fi ;; esac
|
||||
for g in rig-admin rig incus; do
|
||||
case " $want " in
|
||||
*" $g "*)
|
||||
|
|
@ -157,43 +190,68 @@ for u in "${USERS[@]}"; do
|
|||
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.
|
||||
# authorized_keys becomes exactly the file's keys — only the content WRITE
|
||||
# is cmp-guarded, so an unchanged file is a clean no-op. Ownership and mode
|
||||
# converge UNCONDITIONALLY: sshd's StrictModes treats them as load-bearing
|
||||
# (a group-writable .ssh is a rejected key), so drifted perms behind
|
||||
# matching content would otherwise stay broken while apply logs "already
|
||||
# converged". Perms are part of the converged state.
|
||||
home="$(getent passwd "$u" | cut -d: -f6)"
|
||||
ugroup="$(id -gn "$u")"
|
||||
mkdir -p "$home/.ssh"
|
||||
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"
|
||||
chmod 0700 "$home/.ssh"
|
||||
chown "$u:$ugroup" "$home/.ssh"
|
||||
chmod 0600 "$home/.ssh/authorized_keys"
|
||||
chown "$u:$ugroup" "$home/.ssh/authorized_keys"
|
||||
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.
|
||||
# The ledger is what lets a REMOVED user be found at all — so it must REMEMBER
|
||||
# them: two-field lines, 'name active' / 'name revoked' (a legacy bare name
|
||||
# reads as active). Revoked, not deleted: deleting frees the uid for reuse and
|
||||
# orphans file ownership — attribution would rot. Home stays for the same
|
||||
# reason. But revoked must actually mean revoked: a '!'-locked password is not
|
||||
# a closed door under UsePAM — Debian sshd still honors the pubkey — so the
|
||||
# lock alone left a dropped operator with working SSH. Account expiry (a date
|
||||
# in the past) is the switch PAM actually enforces, against every auth method
|
||||
# including keys; the keys themselves are renamed, never deleted — access
|
||||
# revoked, data kept, convergence never destroys.
|
||||
LEDGER=/etc/rig/users
|
||||
REVOKED=()
|
||||
if [ -r "$LEDGER" ]; then
|
||||
while IFS= read -r prev; do
|
||||
while read -r prev pstate _; do
|
||||
[ -n "$prev" ] || continue
|
||||
case " ${USERS[*]:-} " in *" $prev "*) continue ;; esac
|
||||
id -u "$prev" >/dev/null 2>&1 || continue
|
||||
usermod -L "$prev"
|
||||
usermod -L -e 1 "$prev"
|
||||
prevhome="$(getent passwd "$prev" | cut -d: -f6)"
|
||||
if [ -f "$prevhome/.ssh/authorized_keys" ]; then
|
||||
mv "$prevhome/.ssh/authorized_keys" "$prevhome/.ssh/authorized_keys.revoked-by-rig"
|
||||
fi
|
||||
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
|
||||
REVOKED+=("$prev")
|
||||
# Warn on the TRANSITION only: an already-revoked user is converged above
|
||||
# (quietly — repairing drift, not announcing news) so a second identical
|
||||
# run stays a clean no-op.
|
||||
if [ "${pstate:-active}" != "revoked" ]; then
|
||||
warn "$prev is no longer in the file: account expired (blocks SSH keys too, not just the password), authorized_keys renamed to authorized_keys.revoked-by-rig, rig groups stripped (home kept — rig never deletes a user)"
|
||||
CHANGED=1
|
||||
fi
|
||||
done < "$LEDGER"
|
||||
fi
|
||||
LEDGER_TMP="$(mktemp)"
|
||||
if [ "${#USERS[@]}" -gt 0 ]; then printf '%s\n' "${USERS[@]}" > "$LEDGER_TMP"; fi
|
||||
if [ "${#USERS[@]}" -gt 0 ]; then printf '%s active\n' "${USERS[@]}" > "$LEDGER_TMP"; fi
|
||||
if [ "${#REVOKED[@]}" -gt 0 ]; then printf '%s revoked\n' "${REVOKED[@]}" >> "$LEDGER_TMP"; fi
|
||||
if ! cmp -s "$LEDGER_TMP" "$LEDGER" 2>/dev/null; then
|
||||
mkdir -p /etc/rig
|
||||
install -m 0644 "$LEDGER_TMP" "$LEDGER"
|
||||
|
|
|
|||
|
|
@ -26,8 +26,11 @@ Human class ONLY. On class=server, root SSH is the control plane's (Coolify's)
|
|||
automation identity — closing it severs fleet management — so close-root
|
||||
refuses there, with no --force. It also refuses without a role marker (re-run
|
||||
rig bootstrap; never shut the root door blind) and refuses while no rig-admin
|
||||
member holds a working authorized_keys (run rig users apply first; never close
|
||||
the only door).
|
||||
member holds a login sshd would plausibly accept — authorized_keys present
|
||||
and non-empty, home/.ssh/keys owned by the user and not group/world-writable
|
||||
(sshd's StrictModes rejects the key otherwise), a real login shell, account
|
||||
not expired. The refusal names which check failed, per candidate. Run rig
|
||||
users apply first; never close the only door.
|
||||
|
||||
Before running, verify your admin login in a SEPARATE session — `ssh
|
||||
<admin>@<box>` while this one stays open. Root SSH is the door being welded
|
||||
|
|
@ -48,6 +51,16 @@ done
|
|||
# --- guards ------------------------------------------------------------------
|
||||
[ "$(id -u)" -eq 0 ] || die "must run as root"
|
||||
|
||||
# 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
|
||||
# user could reshape who enters this box as whom — the scoped grant silently
|
||||
# root-equivalent through the users family. Direct root (no SUDO_USER:
|
||||
# bring-up, a root shell) proceeds.
|
||||
if [ -n "${SUDO_USER:-}" ] && [ "$SUDO_USER" != "root" ] \
|
||||
&& ! id -nG "$SUDO_USER" 2>/dev/null | tr ' ' '\n' | grep -qx rig-admin; then
|
||||
die "the users family changes who holds root — only rig-admin members (or root itself) may run it; role rig grants operational rig use, not identity management (invoker: $SUDO_USER)"
|
||||
fi
|
||||
|
||||
# Marker gate — the policy lives in assert_marker_human (lib) so the harness
|
||||
# can prove its refusals against fixture markers as non-root; RIG_ROLE_MARKER
|
||||
# exists for the same reason: it keeps the command's own gate pointable at
|
||||
|
|
@ -56,18 +69,69 @@ if ! WHY="$(assert_marker_human "${RIG_ROLE_MARKER:-/etc/rig/role}")"; then
|
|||
die "$WHY"
|
||||
fi
|
||||
|
||||
# Admin-door gate — never close the only door. Root SSH goes away below, so at
|
||||
# least one rig-admin member must already hold a non-empty authorized_keys:
|
||||
# "verified in a separate session" cannot be automated, but a key at the door
|
||||
# can be, and its absence is proof enough to stop.
|
||||
# Admin-door gate — never close the only door. Root SSH goes away below, so
|
||||
# at least one rig-admin member must hold a login sshd would plausibly ACCEPT
|
||||
# — a non-empty authorized_keys alone proves a file exists, not a door:
|
||||
# StrictModes rejects keys behind wrongly-owned or group/world-writable
|
||||
# paths, a nologin shell never logs in, and an expired account fails PAM
|
||||
# before the key is read. So every candidate is checked for the StrictModes
|
||||
# shape, and the refusal names, per candidate, WHICH check failed — an
|
||||
# operator staring at a refusal must see the repair. Honestly: this proves
|
||||
# the door SHOULD open per StrictModes, not that it does — the
|
||||
# verify-in-a-separate-session advisory in --help stays load-bearing.
|
||||
today=$(( $(date +%s) / 86400 ))
|
||||
# path_strict <path> <uid> <label> — flag the two StrictModes complaints
|
||||
flag() { bad="${bad:+$bad, }$1"; }
|
||||
path_strict() {
|
||||
local p="$1" uid="$2" l="$3" o m
|
||||
o="$(stat -c '%u' "$p" 2>/dev/null)" || return 0
|
||||
m="$(stat -c '%a' "$p" 2>/dev/null)"
|
||||
if [ "$o" != "$uid" ]; then flag "$l not owned by the user"; fi
|
||||
if [ $(( 8#$m & 8#022 )) -ne 0 ]; then flag "$l is group/world-writable (mode $m)"; fi
|
||||
}
|
||||
ADMIN_OK=0
|
||||
CANDIDATES=0
|
||||
DETAIL=""
|
||||
while IFS= read -r a; do
|
||||
[ -n "$a" ] || continue
|
||||
h="$(getent passwd "$a" | cut -d: -f6)"
|
||||
if [ -n "$h" ] && [ -s "$h/.ssh/authorized_keys" ]; then ADMIN_OK=1; break; fi
|
||||
CANDIDATES=$((CANDIDATES + 1))
|
||||
bad=""
|
||||
uid="$(id -u "$a" 2>/dev/null)" || { DETAIL="$DETAIL; $a: no such user"; continue; }
|
||||
ent="$(getent passwd "$a")"
|
||||
h="$(printf '%s' "$ent" | cut -d: -f6)"
|
||||
shell="$(printf '%s' "$ent" | cut -d: -f7)"
|
||||
if [ ! -d "$h" ]; then
|
||||
flag "home directory missing"
|
||||
else
|
||||
path_strict "$h" "$uid" "home"
|
||||
if [ ! -d "$h/.ssh" ]; then
|
||||
flag ".ssh directory missing"
|
||||
else
|
||||
path_strict "$h/.ssh" "$uid" ".ssh directory"
|
||||
if [ ! -s "$h/.ssh/authorized_keys" ]; then
|
||||
flag "authorized_keys missing or empty"
|
||||
else
|
||||
path_strict "$h/.ssh/authorized_keys" "$uid" "authorized_keys"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
case "$shell" in
|
||||
*/nologin|*/false) flag "login shell $shell never logs in" ;;
|
||||
esac
|
||||
# shadow field 8 is the expiry in days-since-epoch; empty means never.
|
||||
exp="$(getent shadow "$a" 2>/dev/null | cut -d: -f8)"
|
||||
if [ -n "$exp" ] && [ "$exp" -le "$today" ] 2>/dev/null; then
|
||||
flag "account expired"
|
||||
fi
|
||||
if [ -z "$bad" ]; then ADMIN_OK=1; break; fi
|
||||
DETAIL="$DETAIL; $a: $bad"
|
||||
done < <(getent group rig-admin | cut -d: -f4 | tr ',' '\n')
|
||||
[ "$ADMIN_OK" -eq 1 ] \
|
||||
|| die "no admin user with a key on this box — run rig users apply first; never close the only door"
|
||||
if [ "$ADMIN_OK" -ne 1 ]; then
|
||||
if [ "$CANDIDATES" -eq 0 ]; then
|
||||
die "no admin user with a key on this box — run rig users apply first; never close the only door"
|
||||
fi
|
||||
die "no rig-admin member would pass sshd's door${DETAIL} — repair, prove the login in a separate session, then re-run; never close the only door"
|
||||
fi
|
||||
|
||||
# --- the drop-in --------------------------------------------------------------
|
||||
# The NAME is the entire mechanism: sshd_config is FIRST-wins ("for each
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
#!/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.
|
||||
# ledger's memory of an apply), key counts from authorized_keys, and the
|
||||
# active/revoked state from the ledger CORROBORATED by the account's actual
|
||||
# expiry — apply locks every password always, so the lock flag says nothing;
|
||||
# expiry is the switch that actually revokes, and a mismatch between ledger
|
||||
# and expiry is drift worth shouting about. Reads only — no network, no
|
||||
# writes.
|
||||
set -euo pipefail
|
||||
|
||||
log() { printf 'rig-users: %s\n' "$*"; }
|
||||
|
|
@ -15,8 +19,11 @@ 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).
|
||||
the authorized_keys count ('revoked' when only the .revoked-by-rig rename
|
||||
remains), and whether the user is active or revoked — the ledger's word,
|
||||
checked against the account's real expiry, with a loud warning when the two
|
||||
disagree (a drifted box must never read as healthy). Reads the box only — no
|
||||
network, no writes. Run as root (shadow is read).
|
||||
EOF
|
||||
}
|
||||
|
||||
|
|
@ -36,11 +43,15 @@ if [ ! -r "$LEDGER" ]; then
|
|||
exit 0
|
||||
fi
|
||||
|
||||
while IFS= read -r u; do
|
||||
today=$(( $(date +%s) / 86400 ))
|
||||
while read -r u lstate _; do
|
||||
[ -n "$u" ] || continue
|
||||
# Ledger lines are 'name active' / 'name revoked'; a legacy bare name (a
|
||||
# ledger written before states existed) reads as active.
|
||||
[ -n "${lstate:-}" ] || lstate=active
|
||||
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.
|
||||
# ever revokes. 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
|
||||
|
|
@ -54,12 +65,24 @@ while IFS= read -r u; do
|
|||
keys=0
|
||||
if [ -r "$home/.ssh/authorized_keys" ]; then
|
||||
keys="$(grep -c . "$home/.ssh/authorized_keys" || true)"
|
||||
elif [ -e "$home/.ssh/authorized_keys.revoked-by-rig" ]; then
|
||||
# Only the rename apply's revocation performed remains: access revoked,
|
||||
# data kept.
|
||||
keys=revoked
|
||||
fi
|
||||
# Corroborate the ledger against the switch that actually revokes: shadow
|
||||
# field 8 is the expiry (days since epoch, empty = never). The ledger is
|
||||
# apply's memory; the expiry is the machine's present tense — when they
|
||||
# disagree, someone changed the account behind rig's back.
|
||||
exp="$(getent shadow "$u" 2>/dev/null | cut -d: -f8)"
|
||||
actual=active
|
||||
if [ -n "$exp" ] && [ "$exp" -le "$today" ] 2>/dev/null; then
|
||||
actual=revoked
|
||||
fi
|
||||
if [ "$actual" != "$lstate" ]; then
|
||||
warn "$u: DRIFT — ledger says $lstate but the account's expiry says $actual; re-run rig users apply"
|
||||
log "$u roles=$roles keys=$keys $lstate (DRIFT: expiry says $actual)"
|
||||
else
|
||||
log "$u roles=$roles keys=$keys $lstate"
|
||||
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"
|
||||
|
|
|
|||
40
test/cli.sh
40
test/cli.sh
|
|
@ -314,6 +314,15 @@ 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"
|
||||
# Usernames are validated in the same one-pass refusal matrix: 'fo|o' would
|
||||
# corrupt the parser's own '|'-delimited stream (user 'fo', garbage keys), and
|
||||
# a leading '-' reads as a useradd flag mid-convergence. The refusal names the
|
||||
# line and the rule, like every other parser refusal.
|
||||
printf '%s\n' 'fo|o admin ssh-ed25519 AAAA x' > "$FIX_BAD"
|
||||
check "users parser: '|' in a username is refused" 1 "invalid username" parse "$FIX_BAD"
|
||||
check "users parser: the username refusal names the line" 1 "line 1" parse "$FIX_BAD"
|
||||
printf '%s\n' '-dan admin ssh-ed25519 AAAA x' > "$FIX_BAD"
|
||||
check "users parser: leading-dash username is refused" 1 "invalid username" 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"
|
||||
|
|
@ -347,6 +356,31 @@ sudoers_at="$(grep -nE 'install .*sudoers\.d/rig-roles' "$ROOT/commands/users-ap
|
|||
check "users apply: visudo -c precedes the sudoers install" \
|
||||
0 "" test "${visudo_at:-999999}" -lt "${sudoers_at:-0}"
|
||||
|
||||
# The invoker gate: %rig's sudoers rule is binary-scoped (NOPASSWD for
|
||||
# /usr/local/bin/rig, any args), so without a gate `sudo rig users apply
|
||||
# --file <me-as-admin>` turns role rig root-equivalent through this very
|
||||
# command. Exercising it needs a real SUDO_USER and real groups, so grep the
|
||||
# refusal in both identity-management commands (repo precedent: the
|
||||
# staging/runner tag greps).
|
||||
check "users apply: invoker gate refusal is present" 0 "" \
|
||||
grep -q "changes who holds root" "$ROOT/commands/users-apply.sh"
|
||||
check "users close-root: invoker gate refusal is present" 0 "" \
|
||||
grep -q "changes who holds root" "$ROOT/commands/users-close-root.sh"
|
||||
# Offboarding must revoke SSH, not just the password: a '!'-locked password is
|
||||
# not a closed door under UsePAM — pubkey auth still works. Expiry is the
|
||||
# switch PAM actually honors, and the keys are renamed, never deleted
|
||||
# (convergence never destroys). Needs root + real accounts, so grep both moves.
|
||||
check "users apply: a dropped user's account is expired, not just locked" 0 "" \
|
||||
grep -qF -- "usermod -L -e 1" "$ROOT/commands/users-apply.sh"
|
||||
check "users apply: revoked keys are renamed, never deleted" 0 "" \
|
||||
grep -q "revoked-by-rig" "$ROOT/commands/users-apply.sh"
|
||||
# A fleet-wide users file must not abort apply on a host=no box just because
|
||||
# it names a box-role user somewhere in the fleet: the box role binds where
|
||||
# VMs live, so on host=no it skips (with a warning) and everything else —
|
||||
# admins included — still converges.
|
||||
check "users apply: box role skips on a host=no box" 0 "" \
|
||||
grep -q "box role skipped" "$ROOT/commands/users-apply.sh"
|
||||
|
||||
# --- users close-root: the human-class root-door shutter ---------------------
|
||||
check "users close-root: --help exits 0" 0 "usage:" "$ROOT/commands/users-close-root.sh" --help
|
||||
check "users close-root: unknown flag exits 2" 2 "unknown flag" "$ROOT/commands/users-close-root.sh" --nope
|
||||
|
|
@ -367,6 +401,12 @@ sshdt_at="$(grep -nE '^[[:space:]]*if ! sshd -t' "$ROOT/commands/users-close-roo
|
|||
restart_at="$(grep -n 'systemctl restart ssh' "$ROOT/commands/users-close-root.sh" | head -n1 | cut -d: -f1)"
|
||||
check "users close-root: sshd -t precedes the ssh restart" \
|
||||
0 "" test "${sshdt_at:-999999}" -lt "${restart_at:-0}"
|
||||
# The admin-door gate must check the StrictModes SHAPE, not file existence: a
|
||||
# non-empty authorized_keys behind group/world-writable perms is a key sshd
|
||||
# rejects — closing root behind it welds the only door shut. The full gate
|
||||
# needs root + real accounts, so grep the load-bearing check's wording.
|
||||
check "users close-root: gate checks the StrictModes shape" 0 "" \
|
||||
grep -q "group/world-writable" "$ROOT/commands/users-close-root.sh"
|
||||
# Marker-gate refusals through the sourced lib against fixture markers: the CLI
|
||||
# path sits behind the root check, so the gate is a pure lib function on
|
||||
# purpose (repo precedent: parse_users_file, assert_runner_repo). The command
|
||||
|
|
|
|||
Loading…
Reference in a new issue