Dotfiles/setup/modules/FreeipaAnsible/ansible/ansipa-pull-apply.sh

112 lines
4.7 KiB
Bash
Executable File

#!/bin/bash
# ansipa-pull-apply.sh — fetch, verify, and apply the signed ANSIPA policy repo.
#
# Runs as the unprivileged _ansipa service account (see deploy-ansipa-git-pull.yml)
# on a systemd timer. Replaces the old unsigned SMB-based policystore sync:
# every commit is GPG-signature-verified before anything derived from it runs
# as root, rollback/replay of an old-but-validly-signed commit is refused, and
# no persistent copy of executable policy content sits on disk between runs.
#
# fetch → verify signature → check fast-forward (no rollback) → materialize
# to tmpfs → hand off to root via one exact scoped sudo command → discard.
#
# A fetch failure (git server/network unreachable) is a no-op: this script
# exits at the `git fetch` step and the host keeps running its last-applied,
# already-verified policy set indefinitely.
#
# Paths (fixed — sudoers can't glob a random mktemp path, so WORKDIR is always
# the same tmpfs location, wiped and recreated on every run):
# REPO_DIR /var/lib/ansipa/repo.git bare mirror of ansipa-policy.git
# STATE_FILE /var/lib/ansipa/last-applied-commit
# WORKDIR /run/ansipa/current tmpfs, root:root, wiped after use
#
# Config: /etc/ansipa-pull.conf (optional)
# ANSIPA_NTFY_URL=https://ntfy.sh/your-topic — alert destination (curl -d)
# ANSIPA_GIT_REMOTE=origin
# ANSIPA_GIT_BRANCH=main
set -euo pipefail
CONFIG=/etc/ansipa-pull.conf
[[ -f "$CONFIG" ]] && source "$CONFIG"
REPO_DIR=/var/lib/ansipa/repo.git
STATE_FILE=/var/lib/ansipa/last-applied-commit
GNUPGHOME=/var/lib/ansipa/.gnupg
WORKDIR=/run/ansipa/current
LOCK=/run/ansipa/run.lock
REMOTE="${ANSIPA_GIT_REMOTE:-origin}"
BRANCH="${ANSIPA_GIT_BRANCH:-main}"
NTFY_URL="${ANSIPA_NTFY_URL:-}"
LOG_TAG="ansipa-pull"
export GNUPGHOME
log() { echo "[$LOG_TAG] $*"; logger -t "$LOG_TAG" "$*" 2>/dev/null || true; }
warn() { echo "[$LOG_TAG][WARN] $*" >&2; logger -t "$LOG_TAG" "WARN: $*" 2>/dev/null || true; }
alert() {
warn "$*"
[[ -n "$NTFY_URL" ]] && curl -fsS -m 10 -d "ANSIPA ($(hostname -f 2>/dev/null || hostname)): $*" "$NTFY_URL" &>/dev/null || true
}
mkdir -p /run/ansipa
exec 9>"$LOCK"
flock -n 9 || { log "another run in progress — exiting"; exit 0; }
[[ -d "$REPO_DIR" ]] || { warn "$REPO_DIR missing — not bootstrapped yet, exiting"; exit 0; }
# ── 1. fetch — failure here is the designed no-op path (server/network down) ─
if ! git --git-dir="$REPO_DIR" fetch --quiet "$REMOTE" "$BRANCH"; then
log "fetch failed (server unreachable?) — keeping last-applied policy, exiting"
exit 0
fi
NEW_HEAD=$(git --git-dir="$REPO_DIR" rev-parse "${REMOTE}/${BRANCH}")
LAST_APPLIED=$(cat "$STATE_FILE" 2>/dev/null || echo "")
if [[ "$NEW_HEAD" == "$LAST_APPLIED" ]]; then
exit 0 # no-op: nothing changed since last successful apply
fi
# ── 2. signature verification — hard trust boundary ──────────────────────────
if ! git --git-dir="$REPO_DIR" verify-commit "$NEW_HEAD" 2>/tmp/ansipa-verify.log; then
alert "SIGNATURE VERIFICATION FAILED for $NEW_HEAD — refusing update"
cat /tmp/ansipa-verify.log >&2 || true
exit 1
fi
# ── 3. rollback/replay protection — new HEAD must fast-forward from what we
# last actually applied (skip on the very first run, when nothing was applied yet) ─
if [[ -n "$LAST_APPLIED" ]]; then
if ! git --git-dir="$REPO_DIR" merge-base --is-ancestor "$LAST_APPLIED" "$NEW_HEAD"; then
alert "REJECTED non-fast-forward/rollback update: $LAST_APPLIED -> $NEW_HEAD"
exit 1
fi
fi
# ── 4. materialize into a fresh tmpfs workdir, guaranteed cleanup ────────────
rm -rf "$WORKDIR"
mkdir -p "$WORKDIR"
trap 'rm -rf "$WORKDIR"' EXIT
git --git-dir="$REPO_DIR" archive "$NEW_HEAD" | tar -x -C "$WORKDIR"
# git doesn't track setuid/ownership — never trust anything beyond what we
# explicitly set here. Files extract as _ansipa:_ansipa (this script isn't
# privileged enough to chown to root); that's fine, since the scoped sudo
# step below only needs read access, and root is the one deciding to trust
# and execute this content, not us.
find "$WORKDIR" -type f -exec chmod 644 {} +
find "$WORKDIR" -type d -exec chmod 755 {} +
find "$WORKDIR" -name '*.sh' -exec chmod 744 {} +
find "$WORKDIR" -perm -4000 -exec chmod -x {} + # strip any setuid bit git happened to record
# ── 5. apply — one exact, scoped sudo invocation (see /etc/sudoers.d/ansipa-git-pull) ─
if sudo -n /usr/local/bin/ansipa-enforce-policies.sh; then
echo "$NEW_HEAD" > "$STATE_FILE"
log "applied $NEW_HEAD successfully"
else
alert "policy apply FAILED for $NEW_HEAD — not updating state file"
exit 1
fi