#!/bin/bash # ansipa-git-server-setup.sh — dedicated git-over-SSH server for ANSIPA policy # distribution (see docs/md/freeipa-ansible.md, "Git-based policy distribution"). # # Runs on every container start via ansipa-git-server-setup.service so the bare # repo, hooks, sshd config and keys are always in place after container # recreation (rootfs is ephemeral; only /data survives). # # Design goals (own service, own attack surface — logically separate from the # rest of the ANSIPA container, not a separate LXC): # - Own system user 'git', shell=git-shell (upload-pack/receive-pack only, # no interactive shell even if a key leaks). # - Own sshd instance on a distinct port (default 2222) so the fetch # endpoint is decoupled from however admins shell into this container, and # so nodes can pin a host key for exactly this one endpoint. # - PasswordAuthentication no — public-key auth only. # - Push access requires every commit to carry a valid GPG signature — # enforced server-side by a pre-receive hook (defense in depth; nodes # independently re-verify on fetch, which is the real trust boundary). # # Persistent under /data (survives container recreation): # /data/git-server/repo/ansipa-policy.git bare repo # /data/git-server/ssh_host_*_key* sshd host keys (pinned by nodes) # /data/git-server/authorized_keys admin + deploy public keys # /data/git-server/gnupg/ GPG keyring used by the pre-receive hook # # Environment (first boot only; persisted to /data/git-server/ansipa-git.env # thereafter so re-running without the env vars set is a no-op): # ANSIPA_GIT_SSH_PORT sshd port for this service (default 2222) # ANSIPA_GIT_ADMIN_PUBKEY authoring machine's SSH public key (full push access) # ANSIPA_GIT_SIGNING_PUBKEY armored GPG public key (or path to one) that # signs policy commits — imported so the pre-receive # hook can verify pushes server-side. # # Adding a node's read-only deploy key afterwards (not automated — no defined # transport for per-node key exchange yet, see plan open items): # ansipa-git-add-deploy-key.sh "ssh-ed25519 AAAA... node-fqdn" set -euo pipefail LOG_TAG="ansipa-git-server-setup" GIT_USER="git" GIT_HOME="/srv/git" REPO_DIR="$GIT_HOME/ansipa-policy.git" PERSIST_BASE="/data/git-server" PERSIST_REPO="$PERSIST_BASE/repo/ansipa-policy.git" SSHD_CONFIG="/etc/ssh/sshd_config.ansipa-git" ENV_FILE="$PERSIST_BASE/ansipa-git.env" AUTHKEYS="$PERSIST_BASE/authorized_keys" GNUPGHOME="$PERSIST_BASE/gnupg" log() { echo "[$LOG_TAG] $*"; } warn() { echo "[$LOG_TAG][WARN] $*" >&2; } die() { echo "[$LOG_TAG][ERROR] $*" >&2; exit 1; } mkdir -p "$PERSIST_BASE" "$PERSIST_BASE/repo" "$GNUPGHOME" chmod 700 "$GNUPGHOME" # ── Resolve config (env → persisted file, env wins so operators can rotate) ── GIT_SSH_PORT="${ANSIPA_GIT_SSH_PORT:-}" ADMIN_PUBKEY="${ANSIPA_GIT_ADMIN_PUBKEY:-}" SIGNING_PUBKEY="${ANSIPA_GIT_SIGNING_PUBKEY:-}" if [[ -f "$ENV_FILE" ]]; then # shellcheck source=/dev/null source "$ENV_FILE" GIT_SSH_PORT="${ANSIPA_GIT_SSH_PORT:-${GIT_SSH_PORT:-2222}}" ADMIN_PUBKEY="${ANSIPA_GIT_ADMIN_PUBKEY:-$ADMIN_PUBKEY}" SIGNING_PUBKEY="${ANSIPA_GIT_SIGNING_PUBKEY:-$SIGNING_PUBKEY}" fi GIT_SSH_PORT="${GIT_SSH_PORT:-2222}" { printf 'ANSIPA_GIT_SSH_PORT=%q\n' "$GIT_SSH_PORT" [[ -n "$ADMIN_PUBKEY" ]] && printf 'ANSIPA_GIT_ADMIN_PUBKEY=%q\n' "$ADMIN_PUBKEY" [[ -n "$SIGNING_PUBKEY" ]] && printf 'ANSIPA_GIT_SIGNING_PUBKEY=%q\n' "$SIGNING_PUBKEY" } > "$ENV_FILE" chmod 600 "$ENV_FILE" # ── git-shell must be a valid login shell ──────────────────────────────────── GIT_SHELL_BIN=$(command -v git-shell || echo /usr/libexec/git-core/git-shell) grep -qxF "$GIT_SHELL_BIN" /etc/shells 2>/dev/null || echo "$GIT_SHELL_BIN" >> /etc/shells # ── System user, restricted shell, no password ─────────────────────────────── if ! id "$GIT_USER" &>/dev/null; then useradd -r -m -d "$GIT_HOME" -s "$GIT_SHELL_BIN" "$GIT_USER" passwd -l "$GIT_USER" &>/dev/null || true log "Created system user: $GIT_USER (shell=$GIT_SHELL_BIN)" else usermod -s "$GIT_SHELL_BIN" "$GIT_USER" fi mkdir -p "$GIT_HOME/.ssh" # ── Bare repo — persisted under /data, bind via symlink so paths stay stable ─ mkdir -p "$(dirname "$PERSIST_REPO")" if [[ ! -d "$PERSIST_REPO" ]]; then git init --bare "$PERSIST_REPO" >/dev/null log "Initialized bare repo: $PERSIST_REPO" fi if [[ -e "$REPO_DIR" && ! -L "$REPO_DIR" ]]; then warn "$REPO_DIR exists and is not the expected symlink — leaving it alone" elif [[ ! -e "$REPO_DIR" ]]; then ln -s "$PERSIST_REPO" "$REPO_DIR" fi chown -R "$GIT_USER:$GIT_USER" "$PERSIST_REPO" "$GIT_HOME" chmod 700 "$PERSIST_REPO" # ── pre-receive hook: reject any commit without a valid GPG signature ──────── # GNUPGHOME here holds only the operator's *public* key — never a private key. # Runs as the 'git' user (hooks execute as the pushing account), so give that # account read access to the keyring. cat > "$PERSIST_REPO/hooks/pre-receive" <<'HOOK' #!/bin/bash # Rejects a push if ANY new commit lacks a valid GPG signature from a trusted key. set -euo pipefail export GNUPGHOME="/data/git-server/gnupg" while read -r old_sha new_sha refname; do [[ "$new_sha" =~ ^0+$ ]] && continue # branch/tag deletion — nothing to verify if [[ "$old_sha" =~ ^0+$ ]]; then range="$new_sha" # new branch: verify the whole history being introduced else range="$old_sha..$new_sha" fi while read -r commit; do [[ -z "$commit" ]] && continue if ! git verify-commit "$commit" 2>/tmp/ansipa-verify-commit.log; then echo "REJECTED: commit $commit (ref $refname) has no valid GPG signature" >&2 cat /tmp/ansipa-verify-commit.log >&2 || true exit 1 fi done < <(git rev-list "$range" 2>/dev/null || true) done exit 0 HOOK chmod 750 "$PERSIST_REPO/hooks/pre-receive" chown "$GIT_USER:$GIT_USER" "$PERSIST_REPO/hooks/pre-receive" # ── Import the signing public key so verify-commit (client- and server-side # in the pre-receive hook) can validate it. Accepts either an armored key # string or a path to one. ───────────────────────────────────────────────── if [[ -n "$SIGNING_PUBKEY" ]]; then if [[ -f "$SIGNING_PUBKEY" ]]; then GNUPGHOME="$GNUPGHOME" gpg --batch --import "$SIGNING_PUBKEY" 2>/dev/null \ && log "Imported GPG signing public key from file" else GNUPGHOME="$GNUPGHOME" gpg --batch --import <<< "$SIGNING_PUBKEY" 2>/dev/null \ && log "Imported GPG signing public key from env" fi # Mark it ultimately trusted so `git verify-commit` reports a clean # "Good signature" without a manual trust prompt (single-key trust model — # this is a distribution channel, not a multi-party web of trust). while read -r _fpr; do [[ -z "$_fpr" ]] && continue printf '%s:6:\n' "$_fpr" | GNUPGHOME="$GNUPGHOME" gpg --batch --import-ownertrust 2>/dev/null || true done < <(GNUPGHOME="$GNUPGHOME" gpg --batch --with-colons --list-keys 2>/dev/null | awk -F: '/^fpr:/{print $10}') else warn "ANSIPA_GIT_SIGNING_PUBKEY not set — pre-receive hook will reject ALL pushes until a key is imported" fi chown -R "$GIT_USER:$GIT_USER" "$GNUPGHOME" chmod 700 "$GNUPGHOME" # ── authorized_keys: admin (full push, restricted only by git-shell) + # any previously-added read-only deploy keys (forced-command fetch-only, # defense in depth — GPG signing is the real trust boundary). ─────────────── touch "$AUTHKEYS" if [[ -n "$ADMIN_PUBKEY" ]] && ! grep -qF "$ADMIN_PUBKEY" "$AUTHKEYS" 2>/dev/null; then echo "$ADMIN_PUBKEY" >> "$AUTHKEYS" log "Added/confirmed admin push key in $AUTHKEYS" fi chmod 600 "$AUTHKEYS" cp "$AUTHKEYS" "$GIT_HOME/.ssh/authorized_keys" chown -R "$GIT_USER:$GIT_USER" "$GIT_HOME/.ssh" chmod 700 "$GIT_HOME/.ssh" chmod 600 "$GIT_HOME/.ssh/authorized_keys" # ── Dedicated sshd instance: own port, own host keys, key-auth only ───────── mkdir -p "$PERSIST_BASE/ssh_host_keys" for _type in rsa ed25519; do _key="$PERSIST_BASE/ssh_host_keys/ssh_host_${_type}_key" [[ -f "$_key" ]] || ssh-keygen -q -t "$_type" -f "$_key" -N "" -C "ansipa-git-server" done cat > "$SSHD_CONFIG" <:${GIT_SSH_PORT} → ansipa-policy.git"