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 <noreply@anthropic.com>
main
Amir Alexander Abdelbaki 2026-07-22 15:55:42 +02:00
parent 2a1113018d
commit 4200557757
19 changed files with 835 additions and 65 deletions

View File

@ -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 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 ### 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 <repo>` 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 <last-applied> <new-head>` — 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 <host>` 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@<host>:ansipa-policy.git` — it
propagates fleet-wide within one `ansipa-pull.timer` tick.
---
## Host Group Reference ## Host Group Reference
### Device policies (host groups — applied machine-wide) ### Device policies (host groups — applied machine-wide)

View File

@ -72,11 +72,11 @@
# ansipa-smb-setup.sh (cifs://<host>:<user>:<pass>). # ansipa-smb-setup.sh (cifs://<host>:<user>:<pass>).
# Leaving the group unmounts the share on the next run. # Leaving the group unmounts the share on the next run.
# Requires: cifs-utils installed on the client. # Requires: cifs-utils installed on the client.
# usr_smb_adm_policystore Mount the ansipa policy store at ~/policystore (rw) for members. # 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). # an editing convenience only. Changes made there are NOT picked up
# Also used by the enforcer itself to sync policy files from the # automatically: commit and push (GPG-signed) to the ansipa-policy
# server before applying them — edits on the SMB share take effect # git server, which ansipa-pull-apply.sh verifies and distributes
# on the next 30-min enforcer tick fleet-wide. # fleet-wide. See docs/md/freeipa-ansible.md.
# usr_mon_logins (CheckMK) Local check for SSH login attempts in the last 24 h: # usr_mon_logins (CheckMK) Local check for SSH login attempts in the last 24 h:
# successful + failed + invalid-user counts with thresholds. # successful + failed + invalid-user counts with thresholds.
# WARN: ≥10 failed or ≥5 invalid; CRIT: ≥50 failed or ≥20 invalid. # 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. # - Configure Timeshift (type + target device) before enabling dev_timeshift-backup.
# - CheckMK server must be running and accessible before adding hosts to dev_mon_base. # - 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. # 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. # - Policy files are sourced from /run/ansipa/current/policies.d when the ANSIPA
# The enforcer syncs them from the ansipa-policystore SMB share before each run; # signed git-pull system (ansipa-pull-apply.sh) has populated it — every commit
# edit files on the share directly (~/policystore if you are in usr_smb_adm_policystore) # there has been GPG-verified and checked for fast-forward/no-rollback before
# to update policies without re-running Ansible. # 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 set -euo pipefail
@ -107,10 +110,7 @@ mkdir -p "$STATE_DIR"
log "Discovering groups..." log "Discovering groups..."
_ansipa_discover _ansipa_discover
log "Syncing policy store..." log "Applying policies from $POLICY_DIR..."
_ansipa_sync_policystore
log "Applying policies..."
for _p in "$POLICY_DIR"/*.sh; do for _p in "$POLICY_DIR"/*.sh; do
[[ -f "$_p" ]] || continue [[ -f "$_p" ]] || continue
# shellcheck disable=SC1090 # shellcheck disable=SC1090

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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... <fqdn>"
# - 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

View File

@ -2,9 +2,14 @@
# deploy-ansipa-policies.yml — deploy the policy enforcement daemon to enrolled clients. # 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. # 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). # 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 # this is the fallback/baseline copy. Once a host is also enrolled in the signed
# on the FreeIPA container, so live edits via the share take effect without re-running Ansible. # 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): # Device policies (FreeIPA host groups — applied to the whole machine):
# dev_daemon-enable-<unit> Ensure <unit> is enabled and running; reverted when host leaves group # dev_daemon-enable-<unit> Ensure <unit> is enabled and running; reverted when host leaves group

View File

@ -21,7 +21,21 @@ STATE_DIR="/var/lib/ansipa-policies"
BLOCK_DIR="/usr/local/bin" BLOCK_DIR="/usr/local/bin"
CRON_DIR="/etc/cron.d" CRON_DIR="/etc/cron.d"
LOG_TAG="ansipa-policies" 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 ─────────────────────────────────────────────────────────────────── # ── Logging ───────────────────────────────────────────────────────────────────
log() { echo "[$LOG_TAG] $*"; logger -t "$LOG_TAG" "$*" 2>/dev/null || true; } 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]}" echo "${BASH_REMATCH[1]} ${BASH_REMATCH[2]} ${BASH_REMATCH[3]}"
} }
# ── Policystore sync ────────────────────────────────────────────────────────── # NOTE: this file previously had an _ansipa_sync_policystore() here, which
# Called by the orchestrator after discovery. Reads the SMB credential from the # synced *.sh files from the ansipa-policystore SMB share directly into
# usr_smb_adm_policystore IPA group description, then syncs *.sh files from the # $POLICY_DIR with no integrity check before they were sourced as root. That
# ansipa-policystore SMB share into $POLICY_DIR. # blind-trust path has been removed; policy content now only reaches
# # $POLICY_DIR via ansipa-pull-apply.sh, which requires a valid GPG signature
# On SMB failure: warns and returns 0; the orchestrator continues with whatever # and a fast-forward history before anything is applied. See
# files are already in $POLICY_DIR (Ansible-deployed or previously synced). # docs/md/freeipa-ansible.md, "Git-based policy distribution".
_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"
}

View File

@ -6,6 +6,12 @@
# user is no longer a member. # user is no longer a member.
# Credential: cifs://<host>:smb-policystore:<pass> in the IPA group description, # Credential: cifs://<host>:smb-policystore:<pass> in the IPA group description,
# written by ansipa-smb-setup.sh. # 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_MOUNT_STATE="$STATE_DIR/policystore-mounts"
PS_CREDS_DIR="$STATE_DIR/smb-creds" PS_CREDS_DIR="$STATE_DIR/smb-creds"

View File

@ -34,6 +34,23 @@ CMK_ADMIN_PASSWORD=ChangeMe_CMK!
CMK_SITE_ID=cmk CMK_SITE_ID=cmk
CMK_ADVERTISED_URL= 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) ──────────────────────────────────── # ── nginx gateway (reverse proxy + portal) ────────────────────────────────────
# ANSIPA_HTTP_PORT — host port the ansipa nginx gateway listens on (plain HTTP). # 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 # Put your own TLS-terminating reverse proxy in front of it and forward to

View File

@ -41,6 +41,10 @@ RUN dnf install -y --setopt=install_weak_deps=False \
hostname \ hostname \
samba \ samba \
cronie \ cronie \
git-core \
openssh-server \
openssh-clients \
gnupg2 \
&& dnf clean all \ && dnf clean all \
&& rm -rf /var/cache/dnf && 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-smb.service /etc/systemd/system/ansipa-smb.service
COPY ansipa-checkmk-setup.sh /usr/local/sbin/ansipa-checkmk-setup.sh COPY ansipa-checkmk-setup.sh /usr/local/sbin/ansipa-checkmk-setup.sh
COPY ansipa-checkmk.service /etc/systemd/system/ansipa-checkmk.service 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 \ 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-smb-setup.sh \
&& chmod +x /usr/local/sbin/ansipa-checkmk-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 docker-env.service \
&& systemctl enable ipa-first-boot.service \ && systemctl enable ipa-first-boot.service \
&& systemctl enable ansipa-smb.service \ && systemctl enable ansipa-smb.service \
&& systemctl enable ansipa-checkmk.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 && systemctl enable smb.service nmb.service crond.service
VOLUME ["/data"] VOLUME ["/data"]
# LDAP, LDAPS, Kerberos, kpasswd, HTTPS, DNS, NTP, SMB # 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 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 STOPSIGNAL SIGRTMIN+3
CMD ["/sbin/init"] CMD ["/sbin/init"]

View File

@ -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 \"<ssh-public-key> [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})."

View File

@ -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

View File

@ -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" <<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"

View File

@ -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

View File

@ -83,6 +83,9 @@ services:
# must be routable from enrolled clients (e.g. http://<docker-host>:8090). # must be routable from enrolled clients (e.g. http://<docker-host>:8090).
CMK_ADVERTISED_URL: ${CMK_ADVERTISED_URL:-} CMK_ADVERTISED_URL: ${CMK_ADVERTISED_URL:-}
CMK_SITE_ID: ${CMK_SITE_ID:-cmk} 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: ports:
- "389:389" - "389:389"
- "636:636" - "636:636"
@ -95,6 +98,7 @@ services:
- "139:139" - "139:139"
- "137:137/udp" - "137:137/udp"
- "138:138/udp" - "138:138/udp"
- "${ANSIPA_GIT_SSH_PORT:-2222}:${ANSIPA_GIT_SSH_PORT:-2222}"
networks: networks:
ipa-net: ipa-net:
ipv4_address: 172.30.0.10 ipv4_address: 172.30.0.10

View File

@ -1,12 +1,12 @@
[Unit] [Unit]
Description=Export Docker container env vars for systemd services Description=Export Docker container env vars for systemd services
DefaultDependencies=no 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] [Service]
Type=oneshot Type=oneshot
RemainAfterExit=yes 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] [Install]
WantedBy=sysinit.target WantedBy=sysinit.target

View File

@ -76,6 +76,9 @@ start_freeipa() {
-e CMK_URL="http://172.30.0.12:5000" \ -e CMK_URL="http://172.30.0.12:5000" \
-e CMK_ADVERTISED_URL="${CMK_ADVERTISED_URL:-}" \ -e CMK_ADVERTISED_URL="${CMK_ADVERTISED_URL:-}" \
-e CMK_SITE_ID="${CMK_SITE_ID:-cmk}" \ -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 389:389 \
-p 636:636 \ -p 636:636 \
-p 88:88 \ -p 88:88 \
@ -87,6 +90,7 @@ start_freeipa() {
-p 139:139 \ -p 139:139 \
-p 137:137/udp \ -p 137:137/udp \
-p 138:138/udp \ -p 138:138/udp \
-p "${ANSIPA_GIT_SSH_PORT:-2222}:${ANSIPA_GIT_SSH_PORT:-2222}" \
freeipa-server:local freeipa-server:local
echo "FreeIPA container started. Watch first-boot progress:" echo "FreeIPA container started. Watch first-boot progress:"