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
parent
2a1113018d
commit
4200557757
|
|
@ -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 <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
|
||||
|
||||
### Device policies (host groups — applied machine-wide)
|
||||
|
|
|
|||
|
|
@ -72,11 +72,11 @@
|
|||
# ansipa-smb-setup.sh (cifs://<host>:<user>:<pass>).
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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-<unit> Ensure <unit> is enabled and running; reverted when host leaves group
|
||||
|
|
|
|||
|
|
@ -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".
|
||||
|
|
|
|||
|
|
@ -6,6 +6,12 @@
|
|||
# user is no longer a member.
|
||||
# Credential: cifs://<host>:smb-policystore:<pass> 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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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})."
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
|
|
@ -83,6 +83,9 @@ services:
|
|||
# must be routable from enrolled clients (e.g. http://<docker-host>: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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:"
|
||||
|
|
|
|||
Loading…
Reference in New Issue