Dotfiles/setup/modules/FreeipaAnsible/image/ansipa-git-server-setup.sh

209 lines
9.2 KiB
Bash
Executable File

#!/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" <<CONF
# Managed by ansipa-git-server-setup.sh — do not edit by hand, regenerated on boot.
Port ${GIT_SSH_PORT}
HostKey ${PERSIST_BASE}/ssh_host_keys/ssh_host_rsa_key
HostKey ${PERSIST_BASE}/ssh_host_keys/ssh_host_ed25519_key
PidFile /run/sshd.ansipa-git.pid
PasswordAuthentication no
KbdInteractiveAuthentication no
ChallengeResponseAuthentication no
PermitRootLogin no
PubkeyAuthentication yes
AuthorizedKeysFile ${GIT_HOME}/.ssh/authorized_keys
AllowUsers ${GIT_USER}
X11Forwarding no
AllowTcpForwarding no
AllowAgentForwarding no
PermitTTY no
PrintMotd no
UsePAM no
CONF
log "Wrote $SSHD_CONFIG (port $GIT_SSH_PORT)"
log "Git server ready: ${GIT_USER}@<host>:${GIT_SSH_PORT} → ansipa-policy.git"