209 lines
9.2 KiB
Bash
Executable File
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"
|