From 42005577574b4cd34d01e566a6e7040e11382fa1 Mon Sep 17 00:00:00 2001 From: The_miro Date: Wed, 22 Jul 2026 15:55:42 +0200 Subject: [PATCH] feat(freeipa-ansible): git-based signed policy distribution for ANSIPA Replaces the unsigned SMB-policystore auto-sync (enforcer blindly sourced whatever .sh files sat on the share, no integrity check) with a dedicated git-over-SSH server plus GPG commit signing: nodes pull on a timer, verify every commit's signature and fast-forward history before an unprivileged puller account hands off to root via one exact scoped sudo command, and a fetch failure is a safe no-op that keeps the last-known-good policy running. Co-Authored-By: Claude Sonnet 5 --- docs/md/freeipa-ansible.md | 109 ++++++++- .../ansible/ansipa-enforce-policies.sh | 26 +-- .../ansible/ansipa-git-pull-sudoers | 7 + .../ansible/ansipa-pull-apply.sh | 111 +++++++++ .../ansible/ansipa-pull.service | 11 + .../FreeipaAnsible/ansible/ansipa-pull.timer | 12 + .../ansible/deploy-ansipa-git-pull.yml | 215 ++++++++++++++++++ .../ansible/deploy-ansipa-policies.yml | 11 +- .../ansible/lib/ansipa-policy.sh | 66 ++---- .../ansible/policies.d/usr_policystore.sh | 6 + .../modules/FreeipaAnsible/image/.env.example | 17 ++ setup/modules/FreeipaAnsible/image/Dockerfile | 16 +- .../image/ansipa-git-add-deploy-key.sh | 41 ++++ .../image/ansipa-git-server-setup.service | 17 ++ .../image/ansipa-git-server-setup.sh | 208 +++++++++++++++++ .../image/ansipa-git-sshd.service | 15 ++ .../FreeipaAnsible/image/docker-compose.yml | 4 + .../FreeipaAnsible/image/docker-env.service | 4 +- setup/modules/FreeipaAnsible/image/run.sh | 4 + 19 files changed, 835 insertions(+), 65 deletions(-) create mode 100644 setup/modules/FreeipaAnsible/ansible/ansipa-git-pull-sudoers create mode 100755 setup/modules/FreeipaAnsible/ansible/ansipa-pull-apply.sh create mode 100644 setup/modules/FreeipaAnsible/ansible/ansipa-pull.service create mode 100644 setup/modules/FreeipaAnsible/ansible/ansipa-pull.timer create mode 100644 setup/modules/FreeipaAnsible/ansible/deploy-ansipa-git-pull.yml create mode 100755 setup/modules/FreeipaAnsible/image/ansipa-git-add-deploy-key.sh create mode 100644 setup/modules/FreeipaAnsible/image/ansipa-git-server-setup.service create mode 100755 setup/modules/FreeipaAnsible/image/ansipa-git-server-setup.sh create mode 100644 setup/modules/FreeipaAnsible/image/ansipa-git-sshd.service diff --git a/docs/md/freeipa-ansible.md b/docs/md/freeipa-ansible.md index b449958..2245efc 100644 --- a/docs/md/freeipa-ansible.md +++ b/docs/md/freeipa-ansible.md @@ -244,7 +244,17 @@ Deploys a `systemd.path` unit that triggers on login. Users who are members of t ansible-playbook -i inventory deploy-ansipa-policies.yml ``` -Deploys the shared policy library, all `policies.d/` modules, `ansipa-enforce-policies.sh`, and a systemd timer that runs the enforcer every 30 min. +Deploys the shared policy library, all `policies.d/` modules, `ansipa-enforce-policies.sh`, and a systemd timer that runs the enforcer every 30 min. Installs the Ansible-deployed fallback copy of `policies.d/` — see [Git-Based Policy Distribution](#git-based-policy-distribution) for the live, signed update path that supersedes it. + +### Deploy Signed Git-Based Policy Puller + +```bash +ansible-playbook -i inventory deploy-ansipa-git-pull.yml \ + -e ansipa_git_host=ipa.corp.example.com -e ansipa_git_port=2222 \ + -e @vault-ansipa-git-pull.yml # ansipa_git_host_key, ansipa_git_deploy_key, ansipa_gpg_pubkey +``` + +Run this *after* `deploy-ansipa-policies.yml`. Bootstraps the unprivileged `_ansipa` puller account, pins the git server's SSH host key, installs this node's read-only deploy key and the operator's GPG public key, clones the bare mirror, and installs `ansipa-pull-apply.sh` + a systemd timer (10 min, ±90s jitter) plus a sudoers drop-in scoped to one exact command. See [Git-Based Policy Distribution](#git-based-policy-distribution) below. ### Collect LUKS Backup Keys @@ -273,6 +283,103 @@ Keys on the SMB share are accessible only to `KeyAdmin` group members (see [SMB --- +## Git-Based Policy Distribution + +The old distribution path — an enforcer running as root, blind-syncing whatever +`.sh` files sat on the `ansipa-policystore` SMB share and `source`-ing them +with no integrity check — is gone. Policy content now reaches a node only via +a GPG-signed git commit, verified before anything derived from it runs as +root. + +**Goal:** nodes pull on a timer; a pull failure (git server/network +unreachable) is a no-op — the node keeps running its last-known-good, +already-verified policy indefinitely. Rollback/replay of an old-but-validly- +signed commit is detected and refused. No persistent, executable copy of +policy scripts sits on disk between runs. + +### Components + +| Piece | Where | Runs as | +|---|---|---| +| `ansipa-git-server-setup.sh` / `ansipa-git-sshd.service` | FreeIPA container (`image/`) | root (container) | +| `ansipa-policy.git` (bare repo) | `/data/git-server/repo/` on the container, persisted | owned by `git` | +| `ansipa-pull-apply.sh` / `ansipa-pull.timer` | every enrolled client (`ansible/`) | `_ansipa` (unprivileged) | +| `ansipa-enforce-policies.sh` | every enrolled client | root, via one scoped `sudo` command | + +**Git server** — rolled into the same ANSIPA container as everything else +("own service, own attack surface" means *logically* separate, not a +separate LXC): a dedicated system user `git` with shell `git-shell` +(`git-upload-pack`/`git-receive-pack` only, no interactive shell even if a +key leaks), and its own `sshd` instance on a distinct port +(`ANSIPA_GIT_SSH_PORT`, default 2222) with `PasswordAuthentication no`. +The admin/authoring key gets full push access (restricted only by +`git-shell`); per-node read-only deploy keys are additionally forced to +`git-upload-pack ` via `command=` in `authorized_keys` (defense in +depth — GPG signing is the real trust boundary regardless). Register a +node's deploy key with: + +```bash +docker exec freeipa ansipa-git-add-deploy-key.sh "ssh-ed25519 AAAA...== node.example.com" +``` + +**Commit signing** — every commit must be GPG-signed on the authoring +machine (`git config commit.gpgsign true`); a `pre-receive` hook on the +server (`git verify-commit` on every incoming commit) rejects unsigned or +badly-signed pushes as defense in depth. The signing *private* key never +leaves the authoring machine — only the public key (`ANSIPA_GIT_SIGNING_PUBKEY`) +is on the container. + +**Node-side puller (`ansipa-pull-apply.sh`, run by `_ansipa` via +`ansipa-pull.timer`)** — each run: + +1. `git fetch` the mirror. Failure here (server/network down) is the + designed no-op path — exit, last-applied policy keeps running. +2. `git verify-commit` the new `HEAD`. Failure → hard refusal + alert, + nothing executed. +3. `git merge-base --is-ancestor ` — refuses + anything that isn't a fast-forward (blocks rollback/replay of an + old-but-validly-signed commit). +4. Materializes the verified tree into a fresh tmpfs workdir + (`/run/ansipa/current`), normalizing permissions (no setuid, no + unexpected modes) since git doesn't track real ownership/setuid bits. + Wiped before use and unconditionally wiped after via `trap ... EXIT` — + executable policy content exists on disk only for the duration of one run. +5. Hands off to root via **one exact, argument-free** `sudo` command + (`/etc/sudoers.d/ansipa-git-pull`): `sudo -n /usr/local/bin/ansipa-enforce-policies.sh`. + A compromised `_ansipa` account cannot pivot to arbitrary root execution — + it can only trigger the enforcer, which only does what the + already-verified commit told it to. +6. Only on a successful apply does `/var/lib/ansipa/last-applied-commit` + advance, so a failed apply doesn't get silently skipped next time. + +`lib/ansipa-policy.sh` resolves `POLICY_DIR` to `/run/ansipa/current/policies.d` +when the puller has populated it (the live, verified source), falling back to +the Ansible-deployed `/usr/local/lib/ansipa/policies.d` baseline otherwise — +there is no third, unverified source. `usr_smb_adm_policystore` (the +`~/policystore` SMB mount) still exists as an **editing convenience only**: +changes made there take effect only once committed, signed, and pushed. + +### Bootstrap order + +1. `image/ansipa-git-server-setup.sh` runs automatically on the FreeIPA + container (`ansipa-git-server-setup.service` + `ansipa-git-sshd.service`); + set `ANSIPA_GIT_ADMIN_PUBKEY` / `ANSIPA_GIT_SIGNING_PUBKEY` in `.env` first. +2. Fetch and **verify out of band** the container's git-sshd host key + (`ssh-keyscan -p 2222 ` alone is not verification — confirm the + fingerprint through a separate channel). +3. `ansible-playbook deploy-ansipa-policies.yml` (baseline enforcer + fallback + policies). +4. `ansible-playbook deploy-ansipa-git-pull.yml` with the pinned host key, + this node's deploy private key, and the operator's GPG public key + (vault these). +5. Register the node's deploy key on the server: + `ansipa-git-add-deploy-key.sh`. +6. Author policy changes on a git-signing-capable machine, commit with + `commit.gpgsign=true`, push to `git@:ansipa-policy.git` — it + propagates fleet-wide within one `ansipa-pull.timer` tick. + +--- + ## Host Group Reference ### Device policies (host groups — applied machine-wide) diff --git a/setup/modules/FreeipaAnsible/ansible/ansipa-enforce-policies.sh b/setup/modules/FreeipaAnsible/ansible/ansipa-enforce-policies.sh index a978b97..9062068 100755 --- a/setup/modules/FreeipaAnsible/ansible/ansipa-enforce-policies.sh +++ b/setup/modules/FreeipaAnsible/ansible/ansipa-enforce-policies.sh @@ -72,11 +72,11 @@ # ansipa-smb-setup.sh (cifs://::). # Leaving the group unmounts the share on the next run. # Requires: cifs-utils installed on the client. -# usr_smb_adm_policystore Mount the ansipa policy store at ~/policystore (rw) for members. -# Credential in IPA group description (set by ansipa-smb-setup.sh). -# Also used by the enforcer itself to sync policy files from the -# server before applying them — edits on the SMB share take effect -# on the next 30-min enforcer tick fleet-wide. +# usr_smb_adm_policystore Mount the ansipa policy store at ~/policystore (rw) for members — +# an editing convenience only. Changes made there are NOT picked up +# automatically: commit and push (GPG-signed) to the ansipa-policy +# git server, which ansipa-pull-apply.sh verifies and distributes +# fleet-wide. See docs/md/freeipa-ansible.md. # usr_mon_logins (CheckMK) Local check for SSH login attempts in the last 24 h: # successful + failed + invalid-user counts with thresholds. # WARN: ≥10 failed or ≥5 invalid; CRIT: ≥50 failed or ≥20 invalid. @@ -87,10 +87,13 @@ # - Configure Timeshift (type + target device) before enabling dev_timeshift-backup. # - CheckMK server must be running and accessible before adding hosts to dev_mon_base. # Run ansipa-checkmk-setup.sh on the FreeIPA container to seed credentials and groups. -# - Policy files live in /usr/local/lib/ansipa/policies.d/ on enrolled clients. -# The enforcer syncs them from the ansipa-policystore SMB share before each run; -# edit files on the share directly (~/policystore if you are in usr_smb_adm_policystore) -# to update policies without re-running Ansible. +# - Policy files are sourced from /run/ansipa/current/policies.d when the ANSIPA +# signed git-pull system (ansipa-pull-apply.sh) has populated it — every commit +# there has been GPG-verified and checked for fast-forward/no-rollback before +# landing. Falls back to the Ansible-deployed /usr/local/lib/ansipa/policies.d +# baseline otherwise (see lib/ansipa-policy.sh POLICY_DIR resolution). +# To ship a policy change fleet-wide: edit under version control, sign the +# commit, and push to the ansipa-policy git server — NOT the old SMB share. set -euo pipefail @@ -107,10 +110,7 @@ mkdir -p "$STATE_DIR" log "Discovering groups..." _ansipa_discover -log "Syncing policy store..." -_ansipa_sync_policystore - -log "Applying policies..." +log "Applying policies from $POLICY_DIR..." for _p in "$POLICY_DIR"/*.sh; do [[ -f "$_p" ]] || continue # shellcheck disable=SC1090 diff --git a/setup/modules/FreeipaAnsible/ansible/ansipa-git-pull-sudoers b/setup/modules/FreeipaAnsible/ansible/ansipa-git-pull-sudoers new file mode 100644 index 0000000..1c56300 --- /dev/null +++ b/setup/modules/FreeipaAnsible/ansible/ansipa-git-pull-sudoers @@ -0,0 +1,7 @@ +# Managed by deploy-ansipa-git-pull.yml — do not edit by hand. +# +# Scoped to one exact command, no arguments, no argument substitution: a +# compromised _ansipa account can invoke the enforcer (which only does what +# the last GPG-signature-verified, fast-forward commit told it to) and +# nothing else — it cannot pivot to arbitrary root execution. +_ansipa ALL=(root) NOPASSWD: /usr/local/bin/ansipa-enforce-policies.sh diff --git a/setup/modules/FreeipaAnsible/ansible/ansipa-pull-apply.sh b/setup/modules/FreeipaAnsible/ansible/ansipa-pull-apply.sh new file mode 100755 index 0000000..0bcf032 --- /dev/null +++ b/setup/modules/FreeipaAnsible/ansible/ansipa-pull-apply.sh @@ -0,0 +1,111 @@ +#!/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 diff --git a/setup/modules/FreeipaAnsible/ansible/ansipa-pull.service b/setup/modules/FreeipaAnsible/ansible/ansipa-pull.service new file mode 100644 index 0000000..98e21a0 --- /dev/null +++ b/setup/modules/FreeipaAnsible/ansible/ansipa-pull.service @@ -0,0 +1,11 @@ +[Unit] +Description=ANSIPA signed policy pull-and-apply +Wants=network-online.target +After=network-online.target + +[Service] +Type=oneshot +User=_ansipa +ExecStart=/usr/local/bin/ansipa-pull-apply.sh +StandardOutput=journal +StandardError=journal diff --git a/setup/modules/FreeipaAnsible/ansible/ansipa-pull.timer b/setup/modules/FreeipaAnsible/ansible/ansipa-pull.timer new file mode 100644 index 0000000..3a4b5eb --- /dev/null +++ b/setup/modules/FreeipaAnsible/ansible/ansipa-pull.timer @@ -0,0 +1,12 @@ +[Unit] +Description=Run ANSIPA signed policy pull-and-apply periodically + +[Timer] +OnBootSec=2min +OnUnitActiveSec=10min +# Avoids a thundering herd of every node hitting the git server on the same tick. +RandomizedDelaySec=90 +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/setup/modules/FreeipaAnsible/ansible/deploy-ansipa-git-pull.yml b/setup/modules/FreeipaAnsible/ansible/deploy-ansipa-git-pull.yml new file mode 100644 index 0000000..a621cc7 --- /dev/null +++ b/setup/modules/FreeipaAnsible/ansible/deploy-ansipa-git-pull.yml @@ -0,0 +1,215 @@ +--- +# deploy-ansipa-git-pull.yml — bootstrap the signed git-based policy puller. +# +# Replaces the old unsigned SMB-policystore auto-sync with a pull mechanism +# where every applied commit is GPG-signature-verified and checked for +# fast-forward (no rollback/replay) before anything runs as root. See +# docs/md/freeipa-ansible.md, "Git-based policy distribution", for the full +# design and threat model. +# +# Prerequisites: +# - deploy-ansipa-policies.yml already run on this host (installs +# ansipa-enforce-policies.sh + the Ansible-deployed policies.d baseline +# that this system falls back to before the first successful pull). +# - The ANSIPA git server is up (image/ansipa-git-server-setup.sh, run on +# the FreeIPA container) and this node's read-only deploy key has been +# registered there: +# docker exec freeipa ansipa-git-add-deploy-key.sh "ssh-ed25519 AAAA... " +# - The git server's SSH host key has been fetched and verified OUT OF BAND +# (never trust an unauthenticated ssh-keyscan) — pass it as +# ansipa_git_host_key. This is what actually stops a DNS-spoof-to-attacker +# scenario; GPG signing is what stops tampered content even if transport +# trust were somehow broken. +# +# Required variables (pass via -e, host_vars, or an Ansible Vault file): +# ansipa_git_host FQDN/IP of the ANSIPA git server +# ansipa_git_port sshd port for the git service (default 2222) +# ansipa_git_host_key exact known_hosts line(s) for ansipa_git_host, +# e.g. "[host]:2222 ssh-ed25519 AAAA..." +# ansipa_git_deploy_key this node's PRIVATE deploy key (vault this) +# ansipa_gpg_pubkey armored GPG public key that signs policy commits +# +# Optional: +# ansipa_seed_commit commit SHA to seed last-applied-commit with, so an +# already-configured fleet doesn't re-apply the +# entire policy history on first pull. +# ansipa_ntfy_url alert destination (curl -d) for verify/apply failures +# +# Usage: +# ansible-playbook -i inventory deploy-ansipa-git-pull.yml \ +# -e ansipa_git_host=ipa.corp.example.com -e ansipa_git_port=2222 \ +# -e @vault-ansipa-git-pull.yml + +- name: Bootstrap ANSIPA signed git policy puller + hosts: all + become: yes + + vars: + ansipa_git_port: 2222 + ansipa_state_dir: /var/lib/ansipa + + tasks: + + - name: Require the git-pull identity variables + assert: + that: + - ansipa_git_host is defined + - ansipa_git_host_key is defined + - ansipa_git_deploy_key is defined + - ansipa_gpg_pubkey is defined + fail_msg: >- + ansipa_git_host, ansipa_git_host_key, ansipa_git_deploy_key and + ansipa_gpg_pubkey are all required — see the header of this playbook. + + - name: Install git + package: + name: git + state: present + + - name: Create _ansipa service account (nologin, home = state dir) + user: + name: _ansipa + system: yes + shell: /usr/sbin/nologin + home: "{{ ansipa_state_dir }}" + create_home: no + password_lock: yes + + - name: Create ansipa state/ssh/gnupg directories + file: + path: "{{ item.path }}" + state: directory + owner: _ansipa + group: _ansipa + mode: "{{ item.mode }}" + loop: + - { path: "{{ ansipa_state_dir }}", mode: "0700" } + - { path: "{{ ansipa_state_dir }}/.ssh", mode: "0700" } + - { path: "{{ ansipa_state_dir }}/.gnupg", mode: "0700" } + + - name: Pin the ANSIPA git server host key (StrictHostKeyChecking=yes — the + transport-level defense against a spoofed/attacker-controlled endpoint) + copy: + dest: "{{ ansipa_state_dir }}/.ssh/known_hosts" + content: "{{ ansipa_git_host_key }}\n" + owner: _ansipa + group: _ansipa + mode: "0644" + + - name: Install this node's read-only deploy key + copy: + dest: "{{ ansipa_state_dir }}/.ssh/deploy_key" + content: "{{ ansipa_git_deploy_key }}" + owner: _ansipa + group: _ansipa + mode: "0600" + + - name: Write SSH client config for the ansipa-git alias + copy: + dest: "{{ ansipa_state_dir }}/.ssh/config" + owner: _ansipa + group: _ansipa + mode: "0600" + content: | + Host ansipa-git + HostName {{ ansipa_git_host }} + Port {{ ansipa_git_port }} + User git + IdentityFile {{ ansipa_state_dir }}/.ssh/deploy_key + IdentitiesOnly yes + UserKnownHostsFile {{ ansipa_state_dir }}/.ssh/known_hosts + StrictHostKeyChecking yes + + - name: Write the GPG public key that signs policy commits + copy: + dest: "{{ ansipa_state_dir }}/policy-signing-pubkey.asc" + content: "{{ ansipa_gpg_pubkey }}" + owner: _ansipa + group: _ansipa + mode: "0644" + + - name: Import the signing public key into _ansipa's keyring + become: yes + become_user: _ansipa + environment: + GNUPGHOME: "{{ ansipa_state_dir }}/.gnupg" + HOME: "{{ ansipa_state_dir }}" + command: gpg --batch --import {{ ansipa_state_dir }}/policy-signing-pubkey.asc + register: _gpg_import + changed_when: "'imported' in _gpg_import.stderr or 'unchanged' in _gpg_import.stderr" + + - name: Trust the imported signing key (single-key distribution channel, + not a multi-party web of trust — ultimate trust avoids a manual prompt) + become: yes + become_user: _ansipa + environment: + GNUPGHOME: "{{ ansipa_state_dir }}/.gnupg" + HOME: "{{ ansipa_state_dir }}" + shell: | + set -euo pipefail + gpg --batch --with-colons --list-keys | awk -F: '/^fpr:/{print $10 ":6:"}' \ + | gpg --batch --import-ownertrust + changed_when: false + + - name: Bootstrap the bare mirror clone (idempotent — skipped if already present) + become: yes + become_user: _ansipa + environment: + HOME: "{{ ansipa_state_dir }}" + command: git clone --mirror ansipa-git:ansipa-policy.git {{ ansipa_state_dir }}/repo.git + args: + creates: "{{ ansipa_state_dir }}/repo.git" + + - name: Seed last-applied-commit (avoids re-applying full history on an + already-configured fleet); leave absent to apply from the very first commit + copy: + dest: "{{ ansipa_state_dir }}/last-applied-commit" + content: "{{ ansipa_seed_commit }}\n" + owner: _ansipa + group: _ansipa + mode: "0600" + force: no + when: ansipa_seed_commit is defined + + - name: Write /etc/ansipa-pull.conf + copy: + dest: /etc/ansipa-pull.conf + mode: "0644" + content: | + # ansipa-pull configuration — managed by deploy-ansipa-git-pull.yml + ANSIPA_GIT_REMOTE=origin + ANSIPA_GIT_BRANCH=main + {% if ansipa_ntfy_url is defined %}ANSIPA_NTFY_URL={{ ansipa_ntfy_url }}{% endif %} + + - name: Deploy ansipa-pull-apply.sh + copy: + src: ansipa-pull-apply.sh + dest: /usr/local/bin/ansipa-pull-apply.sh + mode: "0755" + + - name: Deploy scoped sudoers drop-in (one exact command, no arguments) + copy: + src: ansipa-git-pull-sudoers + dest: /etc/sudoers.d/ansipa-git-pull + owner: root + group: root + mode: "0440" + validate: "visudo -cf %s" + + - name: Install ansipa-pull systemd service + timer + copy: + src: "{{ item }}" + dest: "/etc/systemd/system/{{ item }}" + mode: "0644" + loop: + - ansipa-pull.service + - ansipa-pull.timer + + - name: Reload systemd + command: systemctl daemon-reload + + - name: Enable and start the ansipa-pull timer + systemd: + name: ansipa-pull.timer + enabled: yes + state: started diff --git a/setup/modules/FreeipaAnsible/ansible/deploy-ansipa-policies.yml b/setup/modules/FreeipaAnsible/ansible/deploy-ansipa-policies.yml index 6bcf7a5..7ba98a9 100644 --- a/setup/modules/FreeipaAnsible/ansible/deploy-ansipa-policies.yml +++ b/setup/modules/FreeipaAnsible/ansible/deploy-ansipa-policies.yml @@ -2,9 +2,14 @@ # deploy-ansipa-policies.yml — deploy the policy enforcement daemon to enrolled clients. # # Installs ansipa-enforce-policies.sh and a systemd timer that runs it every 30 minutes. -# Policy logic lives in /usr/local/lib/ansipa/policies.d/ (one .sh file per policy type). -# At runtime the enforcer also syncs those files from the ansipa-policystore SMB share -# on the FreeIPA container, so live edits via the share take effect without re-running Ansible. +# Policy logic lives in /usr/local/lib/ansipa/policies.d/ (one .sh file per policy type) — +# this is the fallback/baseline copy. Once a host is also enrolled in the signed +# git-pull system (run deploy-ansipa-git-pull.yml after this playbook), verified +# updates land in /run/ansipa/current/policies.d instead and take priority; see +# lib/ansipa-policy.sh and docs/md/freeipa-ansible.md ("Git-based policy +# distribution"). Live, unauthenticated syncing from the ansipa-policystore SMB +# share has been removed — that share is now an editing convenience only, and +# changes there must be committed, GPG-signed, and pushed to take effect. # # Device policies (FreeIPA host groups — applied to the whole machine): # dev_daemon-enable- Ensure is enabled and running; reverted when host leaves group diff --git a/setup/modules/FreeipaAnsible/ansible/lib/ansipa-policy.sh b/setup/modules/FreeipaAnsible/ansible/lib/ansipa-policy.sh index c54db85..cccf66d 100644 --- a/setup/modules/FreeipaAnsible/ansible/lib/ansipa-policy.sh +++ b/setup/modules/FreeipaAnsible/ansible/lib/ansipa-policy.sh @@ -21,7 +21,21 @@ STATE_DIR="/var/lib/ansipa-policies" BLOCK_DIR="/usr/local/bin" CRON_DIR="/etc/cron.d" LOG_TAG="ansipa-policies" -POLICY_DIR="/usr/local/lib/ansipa/policies.d" + +# Policy source resolution: /run/ansipa/current is a tmpfs workdir populated +# fresh on every ansipa-pull-apply.sh run with content whose commit has ALREADY +# been GPG-signature-verified and fast-forward-checked (see ansipa-pull-apply.sh +# and deploy-ansipa-git-pull.yml) — it is the trusted, live source once the git +# pull system is bootstrapped. Before that first successful pull (or if a node +# is never enrolled in it), fall back to the Ansible-deployed baseline at +# /usr/local/lib/ansipa/policies.d. There is no third, unverified source — +# the old SMB-policystore auto-sync (which sourced whatever unsigned .sh files +# sat on the share, with no integrity check) has been removed. +if [[ -d /run/ansipa/current/policies.d ]]; then + POLICY_DIR="/run/ansipa/current/policies.d" +else + POLICY_DIR="/usr/local/lib/ansipa/policies.d" +fi # ── Logging ─────────────────────────────────────────────────────────────────── log() { echo "[$LOG_TAG] $*"; logger -t "$LOG_TAG" "$*" 2>/dev/null || true; } @@ -138,46 +152,10 @@ _smb_parse_cred() { echo "${BASH_REMATCH[1]} ${BASH_REMATCH[2]} ${BASH_REMATCH[3]}" } -# ── Policystore sync ────────────────────────────────────────────────────────── -# Called by the orchestrator after discovery. Reads the SMB credential from the -# usr_smb_adm_policystore IPA group description, then syncs *.sh files from the -# ansipa-policystore SMB share into $POLICY_DIR. -# -# On SMB failure: warns and returns 0; the orchestrator continues with whatever -# files are already in $POLICY_DIR (Ansible-deployed or previously synced). -_ansipa_sync_policystore() { - command -v smbclient &>/dev/null || return 0 - - local _cred_line _host _suser _spass - _cred_line=$(_smb_parse_cred "usr_smb_adm_policystore" 2>/dev/null || true) - if [[ -z "$_cred_line" ]]; then - return 0 # group absent or no credential yet — use local files - fi - read -r _host _suser _spass <<< "$_cred_line" - - local _tmp - _tmp=$(mktemp -d) - - # Build a temporary smbclient credentials file (avoids shell-quoting issues) - local _creds_tmp - _creds_tmp=$(mktemp) - printf 'username=%s\npassword=%s\ndomain=WORKGROUP\n' "$_suser" "$_spass" \ - > "$_creds_tmp" - chmod 600 "$_creds_tmp" - - if smbclient "//${_host}/ansipa-policystore" -A "$_creds_tmp" \ - -c "cd policies.d; mask *.sh; recurse ON; prompt OFF; lcd ${_tmp}; mget *" \ - &>/dev/null - then - # Atomic swap: remove stale files and replace with synced set. - rm -f "$POLICY_DIR"/*.sh 2>/dev/null || true - cp "$_tmp"/*.sh "$POLICY_DIR"/ 2>/dev/null \ - && log "Synced policy store from //${_host}/ansipa-policystore/policies.d" \ - || warn "Policystore sync: no .sh files on share — using local files" - else - warn "Policystore sync failed (SMB unreachable?) — using cached local files" - fi - - rm -rf "$_tmp" - rm -f "$_creds_tmp" -} +# NOTE: this file previously had an _ansipa_sync_policystore() here, which +# synced *.sh files from the ansipa-policystore SMB share directly into +# $POLICY_DIR with no integrity check before they were sourced as root. That +# blind-trust path has been removed; policy content now only reaches +# $POLICY_DIR via ansipa-pull-apply.sh, which requires a valid GPG signature +# and a fast-forward history before anything is applied. See +# docs/md/freeipa-ansible.md, "Git-based policy distribution". diff --git a/setup/modules/FreeipaAnsible/ansible/policies.d/usr_policystore.sh b/setup/modules/FreeipaAnsible/ansible/policies.d/usr_policystore.sh index c17a60b..1bbb611 100644 --- a/setup/modules/FreeipaAnsible/ansible/policies.d/usr_policystore.sh +++ b/setup/modules/FreeipaAnsible/ansible/policies.d/usr_policystore.sh @@ -6,6 +6,12 @@ # user is no longer a member. # Credential: cifs://:smb-policystore: in the IPA group description, # written by ansipa-smb-setup.sh. +# +# This is an EDITING CONVENIENCE ONLY: files saved here are not distributed to +# the fleet automatically. Distribution happens via git — commit the change, +# sign it, and push to the ansipa-policy git server; ansipa-pull-apply.sh on +# each node verifies the signature and applies it. See +# docs/md/freeipa-ansible.md, "Git-based policy distribution". PS_MOUNT_STATE="$STATE_DIR/policystore-mounts" PS_CREDS_DIR="$STATE_DIR/smb-creds" diff --git a/setup/modules/FreeipaAnsible/image/.env.example b/setup/modules/FreeipaAnsible/image/.env.example index c710588..3cc7f6a 100644 --- a/setup/modules/FreeipaAnsible/image/.env.example +++ b/setup/modules/FreeipaAnsible/image/.env.example @@ -34,6 +34,23 @@ CMK_ADMIN_PASSWORD=ChangeMe_CMK! CMK_SITE_ID=cmk CMK_ADVERTISED_URL= +# ── Ansipa git policy server (signed policy distribution) ──────────────────── +# Dedicated git-over-SSH server (own service, own attack surface — see +# docs/md/freeipa-ansible.md) that fleet nodes pull signed policy commits from. +# ANSIPA_GIT_SSH_PORT — sshd port for this service, separate from any +# interactive SSH access to the container (default 2222). +# ANSIPA_GIT_ADMIN_PUBKEY — SSH public key of the machine that authors/pushes +# policy commits (full push access; git-shell-restricted). +# ANSIPA_GIT_SIGNING_PUBKEY — armored GPG public key (or a path to one, if you'd +# rather bind-mount it) used to verify every pushed +# and pulled commit. The matching PRIVATE key must +# never leave the authoring machine. +# Register a node's read-only deploy key afterwards: +# docker exec freeipa ansipa-git-add-deploy-key.sh "ssh-ed25519 AAAA...==" +ANSIPA_GIT_SSH_PORT=2222 +ANSIPA_GIT_ADMIN_PUBKEY= +ANSIPA_GIT_SIGNING_PUBKEY= + # ── nginx gateway (reverse proxy + portal) ──────────────────────────────────── # ANSIPA_HTTP_PORT — host port the ansipa nginx gateway listens on (plain HTTP). # Put your own TLS-terminating reverse proxy in front of it and forward to diff --git a/setup/modules/FreeipaAnsible/image/Dockerfile b/setup/modules/FreeipaAnsible/image/Dockerfile index d4f1b12..022c753 100644 --- a/setup/modules/FreeipaAnsible/image/Dockerfile +++ b/setup/modules/FreeipaAnsible/image/Dockerfile @@ -41,6 +41,10 @@ RUN dnf install -y --setopt=install_weak_deps=False \ hostname \ samba \ cronie \ + git-core \ + openssh-server \ + openssh-clients \ + gnupg2 \ && dnf clean all \ && rm -rf /var/cache/dnf @@ -65,19 +69,27 @@ COPY ansipa-smb-setup.sh /usr/local/sbin/ansipa-smb-setup.sh COPY ansipa-smb.service /etc/systemd/system/ansipa-smb.service COPY ansipa-checkmk-setup.sh /usr/local/sbin/ansipa-checkmk-setup.sh COPY ansipa-checkmk.service /etc/systemd/system/ansipa-checkmk.service +COPY ansipa-git-server-setup.sh /usr/local/sbin/ansipa-git-server-setup.sh +COPY ansipa-git-add-deploy-key.sh /usr/local/sbin/ansipa-git-add-deploy-key.sh +COPY ansipa-git-server-setup.service /etc/systemd/system/ansipa-git-server-setup.service +COPY ansipa-git-sshd.service /etc/systemd/system/ansipa-git-sshd.service RUN chmod +x /usr/local/sbin/ipa-first-boot.sh \ && chmod +x /usr/local/sbin/ansipa-smb-setup.sh \ && chmod +x /usr/local/sbin/ansipa-checkmk-setup.sh \ + && chmod +x /usr/local/sbin/ansipa-git-server-setup.sh \ + && chmod +x /usr/local/sbin/ansipa-git-add-deploy-key.sh \ && systemctl enable docker-env.service \ && systemctl enable ipa-first-boot.service \ && systemctl enable ansipa-smb.service \ && systemctl enable ansipa-checkmk.service \ + && systemctl enable ansipa-git-server-setup.service \ + && systemctl enable ansipa-git-sshd.service \ && systemctl enable smb.service nmb.service crond.service VOLUME ["/data"] -# LDAP, LDAPS, Kerberos, kpasswd, HTTPS, DNS, NTP, SMB -EXPOSE 389 636 88/tcp 88/udp 464/tcp 464/udp 443 80 53/tcp 53/udp 123/udp 445/tcp 445/udp 137/udp 138/udp 139/tcp +# LDAP, LDAPS, Kerberos, kpasswd, HTTPS, DNS, NTP, SMB, ANSIPA git-over-SSH +EXPOSE 389 636 88/tcp 88/udp 464/tcp 464/udp 443 80 53/tcp 53/udp 123/udp 445/tcp 445/udp 137/udp 138/udp 139/tcp 2222/tcp STOPSIGNAL SIGRTMIN+3 CMD ["/sbin/init"] diff --git a/setup/modules/FreeipaAnsible/image/ansipa-git-add-deploy-key.sh b/setup/modules/FreeipaAnsible/image/ansipa-git-add-deploy-key.sh new file mode 100755 index 0000000..70875bf --- /dev/null +++ b/setup/modules/FreeipaAnsible/image/ansipa-git-add-deploy-key.sh @@ -0,0 +1,41 @@ +#!/bin/bash +# ansipa-git-add-deploy-key.sh — register a node's read-only deploy key with +# the ANSIPA git server (see ansipa-git-server-setup.sh). +# +# The key is forced to git-upload-pack (fetch-only) at the transport layer — +# defense in depth; GPG commit signing remains the real trust boundary, so a +# leaked deploy key only grants read access to already-signed history. +# +# Usage (run inside the container, e.g. `docker exec freeipa ...`): +# ansipa-git-add-deploy-key.sh "ssh-ed25519 AAAA...== node.example.com" + +set -euo pipefail + +PERSIST_BASE="/data/git-server" +AUTHKEYS="$PERSIST_BASE/authorized_keys" +GIT_HOME="/srv/git" +REPO_DIR="$GIT_HOME/ansipa-policy.git" + +[[ $# -eq 1 ]] || { echo "Usage: $0 \" [comment]\"" >&2; exit 1; } +PUBKEY="$1" + +[[ "$PUBKEY" =~ ^(ssh-ed25519|ssh-rsa|ecdsa-sha2-) ]] || { + echo "ERROR: doesn't look like an SSH public key" >&2; exit 1; +} + +mkdir -p "$PERSIST_BASE" +touch "$AUTHKEYS" + +LINE="command=\"git-upload-pack ${REPO_DIR}\",no-port-forwarding,no-X11-forwarding,no-pty,no-agent-forwarding ${PUBKEY}" + +if grep -qF "$PUBKEY" "$AUTHKEYS" 2>/dev/null; then + echo "Key already present — skipping." >&2 + exit 0 +fi + +echo "$LINE" >> "$AUTHKEYS" +cp "$AUTHKEYS" "$GIT_HOME/.ssh/authorized_keys" +chown git:git "$GIT_HOME/.ssh/authorized_keys" +chmod 600 "$GIT_HOME/.ssh/authorized_keys" + +echo "Added read-only deploy key (forced git-upload-pack, ${REPO_DIR})." diff --git a/setup/modules/FreeipaAnsible/image/ansipa-git-server-setup.service b/setup/modules/FreeipaAnsible/image/ansipa-git-server-setup.service new file mode 100644 index 0000000..00a4d81 --- /dev/null +++ b/setup/modules/FreeipaAnsible/image/ansipa-git-server-setup.service @@ -0,0 +1,17 @@ +[Unit] +Description=Ansipa git policy server setup (repo, hooks, keys, sshd config) +After=network.target docker-env.service +Wants=docker-env.service + +[Service] +Type=oneshot +RemainAfterExit=yes +# Passwords/keys come from /etc/container.env (written by docker-env.service +# from Docker -e flags) on first boot; persisted thereafter under /data. +EnvironmentFile=-/etc/container.env +ExecStart=/usr/local/sbin/ansipa-git-server-setup.sh +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target diff --git a/setup/modules/FreeipaAnsible/image/ansipa-git-server-setup.sh b/setup/modules/FreeipaAnsible/image/ansipa-git-server-setup.sh new file mode 100755 index 0000000..60fdd6e --- /dev/null +++ b/setup/modules/FreeipaAnsible/image/ansipa-git-server-setup.sh @@ -0,0 +1,208 @@ +#!/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" diff --git a/setup/modules/FreeipaAnsible/image/ansipa-git-sshd.service b/setup/modules/FreeipaAnsible/image/ansipa-git-sshd.service new file mode 100644 index 0000000..a921f68 --- /dev/null +++ b/setup/modules/FreeipaAnsible/image/ansipa-git-sshd.service @@ -0,0 +1,15 @@ +[Unit] +Description=Ansipa git policy server — dedicated sshd instance (git-shell only) +After=ansipa-git-server-setup.service +Requires=ansipa-git-server-setup.service + +[Service] +Type=forking +PIDFile=/run/sshd.ansipa-git.pid +ExecStart=/usr/sbin/sshd -f /etc/ssh/sshd_config.ansipa-git +ExecReload=/bin/kill -HUP $MAINPID +Restart=on-failure +RestartSec=5s + +[Install] +WantedBy=multi-user.target diff --git a/setup/modules/FreeipaAnsible/image/docker-compose.yml b/setup/modules/FreeipaAnsible/image/docker-compose.yml index 25864ce..5bb32b8 100644 --- a/setup/modules/FreeipaAnsible/image/docker-compose.yml +++ b/setup/modules/FreeipaAnsible/image/docker-compose.yml @@ -83,6 +83,9 @@ services: # must be routable from enrolled clients (e.g. http://:8090). CMK_ADVERTISED_URL: ${CMK_ADVERTISED_URL:-} CMK_SITE_ID: ${CMK_SITE_ID:-cmk} + ANSIPA_GIT_SSH_PORT: ${ANSIPA_GIT_SSH_PORT:-2222} + ANSIPA_GIT_ADMIN_PUBKEY: ${ANSIPA_GIT_ADMIN_PUBKEY:-} + ANSIPA_GIT_SIGNING_PUBKEY: ${ANSIPA_GIT_SIGNING_PUBKEY:-} ports: - "389:389" - "636:636" @@ -95,6 +98,7 @@ services: - "139:139" - "137:137/udp" - "138:138/udp" + - "${ANSIPA_GIT_SSH_PORT:-2222}:${ANSIPA_GIT_SSH_PORT:-2222}" networks: ipa-net: ipv4_address: 172.30.0.10 diff --git a/setup/modules/FreeipaAnsible/image/docker-env.service b/setup/modules/FreeipaAnsible/image/docker-env.service index a6fe2f3..4e98b7e 100644 --- a/setup/modules/FreeipaAnsible/image/docker-env.service +++ b/setup/modules/FreeipaAnsible/image/docker-env.service @@ -1,12 +1,12 @@ [Unit] Description=Export Docker container env vars for systemd services DefaultDependencies=no -Before=basic.target ipa-first-boot.service ansipa-smb.service +Before=basic.target ipa-first-boot.service ansipa-smb.service ansipa-git-server-setup.service [Service] Type=oneshot RemainAfterExit=yes -ExecStart=/bin/bash -c "tr '\\0' '\\n' < /proc/1/environ | grep -E '^(IPA|SMB|LUKS|KEYCLOAK)_' > /etc/container.env; chmod 600 /etc/container.env" +ExecStart=/bin/bash -c "tr '\\0' '\\n' < /proc/1/environ | grep -E '^(IPA|SMB|LUKS|KEYCLOAK|ANSIPA_GIT)_' > /etc/container.env; chmod 600 /etc/container.env" [Install] WantedBy=sysinit.target diff --git a/setup/modules/FreeipaAnsible/image/run.sh b/setup/modules/FreeipaAnsible/image/run.sh index 1243804..d3c1586 100755 --- a/setup/modules/FreeipaAnsible/image/run.sh +++ b/setup/modules/FreeipaAnsible/image/run.sh @@ -76,6 +76,9 @@ start_freeipa() { -e CMK_URL="http://172.30.0.12:5000" \ -e CMK_ADVERTISED_URL="${CMK_ADVERTISED_URL:-}" \ -e CMK_SITE_ID="${CMK_SITE_ID:-cmk}" \ + -e ANSIPA_GIT_SSH_PORT="${ANSIPA_GIT_SSH_PORT:-2222}" \ + -e ANSIPA_GIT_ADMIN_PUBKEY="${ANSIPA_GIT_ADMIN_PUBKEY:-}" \ + -e ANSIPA_GIT_SIGNING_PUBKEY="${ANSIPA_GIT_SIGNING_PUBKEY:-}" \ -p 389:389 \ -p 636:636 \ -p 88:88 \ @@ -87,6 +90,7 @@ start_freeipa() { -p 139:139 \ -p 137:137/udp \ -p 138:138/udp \ + -p "${ANSIPA_GIT_SSH_PORT:-2222}:${ANSIPA_GIT_SSH_PORT:-2222}" \ freeipa-server:local echo "FreeIPA container started. Watch first-boot progress:"