#!/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