4 changed files with 197 additions and 2 deletions
|
|
@ -229,7 +229,11 @@ rm -f "$TMP"
|
|||
eff="$(sshd -T 2>/dev/null)" || die "sshd -T failed; refusing to claim a hardened box"
|
||||
echo "$eff" | grep -qx 'passwordauthentication no' \
|
||||
|| die "sshd still resolves passwordauthentication=yes — a drop-in is beating ${DROPIN}; check ls /etc/ssh/sshd_config.d/"
|
||||
echo "$eff" | grep -qxE 'permitrootlogin (prohibit-password|without-password)' \
|
||||
# `no` is accepted because it is the post-`rig users close-root` state —
|
||||
# strictly harder than the prohibit-password this script installs. Bootstrap
|
||||
# must never read a closed door as a broken one, and it cannot reopen one
|
||||
# either: by first-wins its own drop-in loses to 00-rig-users.conf.
|
||||
echo "$eff" | grep -qxE 'permitrootlogin (no|prohibit-password|without-password)' \
|
||||
|| die "sshd still permits root password login — check ls /etc/ssh/sshd_config.d/"
|
||||
log "sshd hardening verified (sshd -T: passwordauthentication no)"
|
||||
|
||||
|
|
|
|||
|
|
@ -79,8 +79,36 @@ parse_users_file() {
|
|||
# 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.
|
||||
# (apply notes it, close-root refuses on it) — this reader only reads.
|
||||
read_role_marker() {
|
||||
[ -r "$1" ] || return 0
|
||||
head -n1 "$1"
|
||||
}
|
||||
|
||||
# assert_marker_human <marker_path> — close-root's marker gate: return 0,
|
||||
# silently, only when the marker says class=human; otherwise print the refusal
|
||||
# reason on stdout and return 1 (the caller wraps it in its own die). The
|
||||
# policy is a pure lib function on purpose: the CLI path sits behind the root
|
||||
# check, so the harness proves every refusal HERE, against fixture markers,
|
||||
# non-root (repo precedent: parse_users_file, assert_runner_repo).
|
||||
assert_marker_human() {
|
||||
local marker
|
||||
marker="$(read_role_marker "$1")"
|
||||
if [ -z "$marker" ]; then
|
||||
# No marker means rig cannot know whether root here is a human's bad habit
|
||||
# or the control plane's automation door — refuse to shut it blind.
|
||||
printf '%s\n' "no /etc/rig/role marker: re-run rig bootstrap so this box knows what it is; refusing to shut the root door blind"
|
||||
return 1
|
||||
fi
|
||||
case "$marker" in
|
||||
*class=human*) return 0 ;;
|
||||
*class=server*)
|
||||
# Root SSH on a server IS the control plane's (Coolify's) automation
|
||||
# identity — closing it severs fleet management. No --force exists.
|
||||
printf '%s\n' "class=server: root here is the control plane's automation identity — closing it severs fleet management"
|
||||
return 1 ;;
|
||||
*)
|
||||
printf '%s\n' "marker names no class (${marker}): re-run rig bootstrap; refusing to shut the root door blind"
|
||||
return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
|
|
|||
112
commands/users-close-root.sh
Executable file
112
commands/users-close-root.sh
Executable file
|
|
@ -0,0 +1,112 @@
|
|||
#!/usr/bin/env bash
|
||||
# rig users close-root — shut the human-class root SSH door, once and only
|
||||
# once a named admin can already get in. class decides root SSH's fate (#26):
|
||||
# on class=human a root login is unattributable noise, so it goes; on
|
||||
# class=server root IS the control plane's automation identity, so closing it
|
||||
# would sever fleet management — this command refuses there, and no --force
|
||||
# exists. Convergent: a second run is a no-op 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' "$*"; }
|
||||
die() { printf 'rig-users: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; }
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
usage: rig users close-root
|
||||
|
||||
Shuts the root SSH door: installs /etc/ssh/sshd_config.d/00-rig-users.conf
|
||||
carrying exactly `PermitRootLogin no`, which beats bootstrap's drop-in by
|
||||
first-wins include order.
|
||||
|
||||
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).
|
||||
|
||||
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
|
||||
shut; the admin door must be proven, not presumed.
|
||||
|
||||
Run as root. Convergent: once root is closed, a re-run is a clean no-op.
|
||||
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
|
||||
|
||||
# --- guards ------------------------------------------------------------------
|
||||
[ "$(id -u)" -eq 0 ] || die "must run as root"
|
||||
|
||||
# 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
|
||||
# fixtures instead of only at the real /etc/rig/role.
|
||||
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_OK=0
|
||||
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
|
||||
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"
|
||||
|
||||
# --- the drop-in --------------------------------------------------------------
|
||||
# The NAME is the entire mechanism: sshd_config is FIRST-wins ("for each
|
||||
# keyword, the first obtained value will be used" — sshd_config(5)), Include
|
||||
# expands its glob in lexical order, and '-' (0x2D) sorts before '.' (0x2E),
|
||||
# so 00-rig-users.conf is read BEFORE bootstrap's 00-rig.conf and this
|
||||
# PermitRootLogin beats its prohibit-password. Rename the file and it silently
|
||||
# loses that fight — the harness asserts the comparison the glob makes.
|
||||
DROPIN=/etc/ssh/sshd_config.d/00-rig-users.conf
|
||||
TMP="$(mktemp)"
|
||||
printf 'PermitRootLogin no\n' > "$TMP"
|
||||
if cmp -s "$TMP" "$DROPIN" 2>/dev/null; then
|
||||
rm -f "$TMP"
|
||||
log "root already closed; nothing to do"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
BACKUP=""
|
||||
[ -e "$DROPIN" ] && { BACKUP="$(mktemp)"; cp -a "$DROPIN" "$BACKUP"; }
|
||||
install -m 0644 "$TMP" "$DROPIN"
|
||||
rm -f "$TMP"
|
||||
|
||||
# Validate the MERGED config BEFORE bouncing the daemon (the bootstrap shape):
|
||||
# on a box whose only door is SSH — exactly what this box is about to become —
|
||||
# restarting into a config the daemon refuses to parse leaves no listener and
|
||||
# no way back in. Roll back and stop rather than shut the door on a maybe.
|
||||
if ! sshd -t 2>/dev/null; then
|
||||
if [ -n "$BACKUP" ]; then cp -a "$BACKUP" "$DROPIN"; else rm -f "$DROPIN"; fi
|
||||
rm -f "$BACKUP"
|
||||
die "sshd rejects the merged config; drop-in rolled back, daemon untouched. Run 'sshd -t' to see which file is bad."
|
||||
fi
|
||||
rm -f "$BACKUP"
|
||||
|
||||
systemctl restart ssh
|
||||
|
||||
# Assert the EFFECTIVE config, not the file's existence — a drop-in sorting
|
||||
# even earlier would win the first-wins fight silently. `sshd -T` is what the
|
||||
# daemon actually resolved.
|
||||
eff="$(sshd -T 2>/dev/null)" || die "sshd -T failed; refusing to claim root is closed"
|
||||
echo "$eff" | grep -qx 'permitrootlogin no' \
|
||||
|| die "sshd still resolves permitrootlogin != no — a drop-in is beating ${DROPIN}; check ls /etc/ssh/sshd_config.d/"
|
||||
log "root door closed (sshd -T resolves permitrootlogin no); humans enter as themselves now"
|
||||
51
test/cli.sh
51
test/cli.sh
|
|
@ -335,6 +335,57 @@ 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}"
|
||||
|
||||
# --- 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
|
||||
# The whole command rests on first-wins + lexical include order: '-' (0x2D)
|
||||
# sorts before '.' (0x2E), so 00-rig-users.conf is read before bootstrap's
|
||||
# 00-rig.conf and its PermitRootLogin wins. Assert the actual comparison the
|
||||
# glob makes, so a renamed drop-in cannot silently lose the fight.
|
||||
check "users close-root: drop-in name sorts before bootstrap's" 0 "" \
|
||||
bash -c '[ "00-rig-users.conf" \< "00-rig.conf" ]'
|
||||
check "users close-root: drop-in name is the load-bearing one" 0 "" \
|
||||
grep -q "00-rig-users.conf" "$ROOT/commands/users-close-root.sh"
|
||||
# Validate-then-apply: `sshd -t` on the merged config must precede the restart —
|
||||
# on a box whose only door is SSH (exactly what this box is about to become),
|
||||
# bouncing the daemon into a config it refuses to parse leaves no way back in.
|
||||
# Match the call, not the word (repo precedent: the repo-guard ordering check);
|
||||
# defaults fail closed.
|
||||
sshdt_at="$(grep -nE '^[[:space:]]*if ! sshd -t' "$ROOT/commands/users-close-root.sh" | head -n1 | cut -d: -f1)"
|
||||
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}"
|
||||
# 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
|
||||
# reads the marker path from RIG_ROLE_MARKER for the same reason — so the gate
|
||||
# stays pointable at fixtures.
|
||||
marker_gate() { # marker_gate <marker_path>
|
||||
bash -c 'set -euo pipefail
|
||||
. "$1/commands/lib/users-config.sh"
|
||||
assert_marker_human "$2"' _ "$ROOT" "$1"
|
||||
}
|
||||
MARKER_DIR="$(mktemp -d)"
|
||||
printf 'role=workload class=server host=no join=authkey\n' > "$MARKER_DIR/server"
|
||||
printf 'role=dev class=human host=yes join=authkey\n' > "$MARKER_DIR/human"
|
||||
check "users close-root: absent marker refuses, names bootstrap as the repair" \
|
||||
1 "no /etc/rig/role marker" marker_gate "$MARKER_DIR/absent"
|
||||
check "users close-root: class=server refuses, names the control plane" \
|
||||
1 "control plane" marker_gate "$MARKER_DIR/server"
|
||||
check "users close-root: class=human passes the gate" \
|
||||
0 "" marker_gate "$MARKER_DIR/human"
|
||||
rm -rf "$MARKER_DIR"
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
check "users close-root: refuses non-root" 1 "must run as root" "$ROOT/commands/users-close-root.sh"
|
||||
else
|
||||
echo "skip: users close-root non-root refusal (running as root)"
|
||||
fi
|
||||
# Bootstrap must read the closed door as hardened, not broken: `no` is the
|
||||
# post-close-root state, strictly harder than what bootstrap installs. Byte-grep
|
||||
# the widened assertion so a revert cannot ship green.
|
||||
check "bootstrap: permitrootlogin assertion accepts the closed state" 0 "" \
|
||||
grep -qF "permitrootlogin (no|prohibit-password|without-password)" "$ROOT/commands/bootstrap.sh"
|
||||
|
||||
# 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