feat(freeipa-ansible): gateway-wide Basic Auth in front of the ansipa portal

Adds a deliberate extra sign-in layer covering the entire nginx gateway
(portal page + every proxied web UI) via a shared htpasswd credential
generated on the FreeIPA container and published to nginx through a new
portal-auth volume. FreeIPA, CheckMK and Keycloak still each enforce their
own login behind it — two prompts beats one attacker who only had to beat
one lock. Healthcheck updated to treat 401 as healthy accordingly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
main
Amir Alexander Abdelbaki 2026-07-22 16:08:10 +02:00
parent 4200557757
commit 4730f89e82
10 changed files with 172 additions and 4 deletions

View File

@ -37,6 +37,10 @@ catalogue and playbook reference see [FreeIPA & Ansible](freeipa-ansible.md).
└─────────────────────────────┘ └────────────────────────────┘ └─────────────────────────────┘ └────────────────────────────┘
``` ```
Every path inside the gateway box requires an extra shared sign-in
(`auth_basic`) before reaching any backend — see
[Sign-in gate in front of the whole gateway](#sign-in-gate-in-front-of-the-whole-gateway).
The design is **pull-based**: the server never initiates connections to clients. The design is **pull-based**: the server never initiates connections to clients.
Every enrolled host runs `ansipa-enforce-policies.sh` on a 30-minute timer, does Every enrolled host runs `ansipa-enforce-policies.sh` on a 30-minute timer, does
`kinit -k host/$FQDN`, reads its own group memberships from FreeIPA, and applies `kinit -k host/$FQDN`, reads its own group memberships from FreeIPA, and applies
@ -183,6 +187,30 @@ The gateway speaks plain HTTP on `ANSIPA_HTTP_PORT` (default **8088**) and trust
`X-Forwarded-*`, so it is designed to sit **behind your own TLS-terminating `X-Forwarded-*`, so it is designed to sit **behind your own TLS-terminating
reverse proxy**. reverse proxy**.
### Sign-in gate in front of the whole gateway
Every path above — the portal *and* every proxied UI — sits behind one shared
HTTP Basic Auth credential first (`auth_basic` at the `server {}` level in
`nginx/templates/default.conf.template`, inherited by every `location`).
FreeIPA, CheckMK and Keycloak still each ask for their own login afterwards —
that's deliberate, not a bug: an attacker now has to beat two locks instead
of one, and each backend's own auth stays fully intact as a second,
independent layer.
- Credential: `ANSIPA_PORTAL_USER` / `ANSIPA_PORTAL_PASSWORD` in `.env`.
Leave the password blank to have one generated on first boot — retrieve it
with `docker exec freeipa journalctl -u ansipa-portal-auth.service`.
- The htpasswd file is generated on the FreeIPA container
(`ansipa-portal-auth-setup.sh`, `apr1` hash — natively understood by nginx,
no dependency on the base image's libc `crypt()`) and published to nginx
via the `portal-auth` shared docker volume (same pattern as `cmk-creds`).
- To add more accounts, re-run the setup script inside the container with a
different `ANSIPA_PORTAL_USER`/`ANSIPA_PORTAL_PASSWORD` pair — it appends
rather than overwrites the htpasswd file.
- An unauthenticated request to `/` now correctly gets **401**, not 200 —
the gateway healthcheck in `docker-compose.yml` treats 401 as healthy for
exactly this reason.
### Exposing ansipa behind your main reverse proxy ### Exposing ansipa behind your main reverse proxy
Point one hostname at the gateway. TLS is terminated by your proxy; ansipa Point one hostname at the gateway. TLS is terminated by your proxy; ansipa
@ -319,7 +347,9 @@ manual intervention is needed — just expect the first enrollment to take a whi
```bash ```bash
# server # server
docker compose ps # all healthy docker compose ps # all healthy
curl -fsS http://DOCKER_HOST_IP:8088/ | grep ansipa # portal curl -s -o /dev/null -w '%{http_code}\n' http://DOCKER_HOST_IP:8088/ # expect 401 (gateway sign-in required)
curl -fsS -u "$ANSIPA_PORTAL_USER:$ANSIPA_PORTAL_PASSWORD" \
http://DOCKER_HOST_IP:8088/ | grep ansipa # portal, authenticated
# client (after enrollment + one enforcer run) # client (after enrollment + one enforcer run)
id admin # SSSD resolves IPA users id admin # SSSD resolves IPA users
kinit admin && klist # Kerberos works kinit admin && klist # Kerberos works

View File

@ -52,6 +52,16 @@ ANSIPA_GIT_ADMIN_PUBKEY=
ANSIPA_GIT_SIGNING_PUBKEY= ANSIPA_GIT_SIGNING_PUBKEY=
# ── nginx gateway (reverse proxy + portal) ──────────────────────────────────── # ── nginx gateway (reverse proxy + portal) ────────────────────────────────────
# ANSIPA_PORTAL_USER / ANSIPA_PORTAL_PASSWORD — deliberate extra sign-in gate
# in front of the ENTIRE gateway (portal page + /ipa/ + /cmk/ + /auth/), on
# top of whatever login each backend already asks for on its own. Two
# prompts beats one attacker who only had to beat one lock.
# Leave ANSIPA_PORTAL_PASSWORD blank to auto-generate one on first boot —
# check `docker exec freeipa journalctl -u ansipa-portal-auth.service` to
# retrieve it, or just set your own here and restart.
ANSIPA_PORTAL_USER=ansipa
ANSIPA_PORTAL_PASSWORD=
# 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
# http://<docker-host>:${ANSIPA_HTTP_PORT}. The gateway fronts all UIs on one # http://<docker-host>:${ANSIPA_HTTP_PORT}. The gateway fronts all UIs on one

View File

@ -45,6 +45,7 @@ RUN dnf install -y --setopt=install_weak_deps=False \
openssh-server \ openssh-server \
openssh-clients \ openssh-clients \
gnupg2 \ gnupg2 \
openssl \
&& dnf clean all \ && dnf clean all \
&& rm -rf /var/cache/dnf && rm -rf /var/cache/dnf
@ -73,17 +74,21 @@ 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-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-server-setup.service /etc/systemd/system/ansipa-git-server-setup.service
COPY ansipa-git-sshd.service /etc/systemd/system/ansipa-git-sshd.service COPY ansipa-git-sshd.service /etc/systemd/system/ansipa-git-sshd.service
COPY ansipa-portal-auth-setup.sh /usr/local/sbin/ansipa-portal-auth-setup.sh
COPY ansipa-portal-auth.service /etc/systemd/system/ansipa-portal-auth.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-server-setup.sh \
&& chmod +x /usr/local/sbin/ansipa-git-add-deploy-key.sh \ && chmod +x /usr/local/sbin/ansipa-git-add-deploy-key.sh \
&& chmod +x /usr/local/sbin/ansipa-portal-auth-setup.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-server-setup.service \
&& systemctl enable ansipa-git-sshd.service \ && systemctl enable ansipa-git-sshd.service \
&& systemctl enable ansipa-portal-auth.service \
&& systemctl enable smb.service nmb.service crond.service && systemctl enable smb.service nmb.service crond.service
VOLUME ["/data"] VOLUME ["/data"]

View File

@ -0,0 +1,81 @@
#!/bin/bash
# ansipa-portal-auth-setup.sh — generate the HTTP Basic Auth credential file
# that gates the ansipa nginx gateway (portal + every proxied web UI).
#
# Runs on every container start via ansipa-portal-auth.service so the
# htpasswd file is always in place after container recreation (rootfs is
# ephemeral; only /data survives). The file itself is written to the shared
# 'portal-auth' volume so the nginx container (which has no access to /data)
# can read it — same pattern as the cmk-creds volume shared with checkmk.
#
# This is a DELIBERATE extra sign-in layer in front of the whole gateway —
# FreeIPA, CheckMK and Keycloak all still enforce their own logins behind it.
# Two prompts beats one attacker who only had to beat one lock.
#
# Password sources (first match wins):
# 1. ANSIPA_PORTAL_PASSWORD env var (first boot / explicit override)
# 2. /data/portal-auth.env (persisted from first boot)
# 3. auto-generated, logged once (so nothing is silently left with a
# predictable/blank credential)
#
# To add more accounts later, hand-edit the persisted file directly:
# printf 'alice:%s\n' "$(openssl passwd -apr1 'their password')" \
# >> /data/portal-auth/htpasswd (then re-copy to the shared volume, or
# just re-run this script with ANSIPA_PORTAL_USER=alice ANSIPA_PORTAL_PASSWORD=...
# — it appends/updates rather than truncating).
set -euo pipefail
LOG_TAG="ansipa-portal-auth-setup"
ENV_FILE="/data/portal-auth.env"
PERSIST_HTPASSWD="/data/portal-auth/htpasswd"
SHARED_HTPASSWD="/portal-auth/htpasswd" # mounted from the portal-auth docker volume
log() { echo "[$LOG_TAG] $*"; }
warn() { echo "[$LOG_TAG][WARN] $*" >&2; }
mkdir -p "$(dirname "$PERSIST_HTPASSWD")" /portal-auth
PORTAL_USER="${ANSIPA_PORTAL_USER:-}"
PORTAL_PASS="${ANSIPA_PORTAL_PASSWORD:-}"
if [[ -f "$ENV_FILE" ]]; then
# shellcheck source=/dev/null
source "$ENV_FILE"
PORTAL_USER="${ANSIPA_PORTAL_USER:-$PORTAL_USER}"
PORTAL_PASS="${ANSIPA_PORTAL_PASSWORD:-$PORTAL_PASS}"
fi
PORTAL_USER="${PORTAL_USER:-ansipa}"
if [[ -z "$PORTAL_PASS" ]]; then
PORTAL_PASS=$(tr -dc 'A-Za-z0-9' < /dev/urandom 2>/dev/null | head -c 32 \
|| openssl rand -base64 24 | tr -d '/+=\n')
warn "ANSIPA_PORTAL_PASSWORD not set — generated one for user '$PORTAL_USER':"
warn " $PORTAL_PASS"
warn "Set ANSIPA_PORTAL_PASSWORD in .env to pin it; otherwise it is reused as-is on every restart."
fi
# ── Persist for subsequent restarts ──────────────────────────────────────────
{
printf 'ANSIPA_PORTAL_USER=%q\n' "$PORTAL_USER"
printf 'ANSIPA_PORTAL_PASSWORD=%q\n' "$PORTAL_PASS"
} > "$ENV_FILE"
chmod 600 "$ENV_FILE"
# ── Write the htpasswd entry (apr1 — natively understood by nginx's
# auth_basic_module, no dependency on the base image's libc crypt()) ─────────
HASH=$(openssl passwd -apr1 "$PORTAL_PASS")
touch "$PERSIST_HTPASSWD"
# Replace this user's line if present, else append.
grep -vE "^${PORTAL_USER}:" "$PERSIST_HTPASSWD" > "${PERSIST_HTPASSWD}.tmp" 2>/dev/null || true
printf '%s:%s\n' "$PORTAL_USER" "$HASH" >> "${PERSIST_HTPASSWD}.tmp"
mv "${PERSIST_HTPASSWD}.tmp" "$PERSIST_HTPASSWD"
chmod 644 "$PERSIST_HTPASSWD"
# ── Publish to the volume nginx reads from ───────────────────────────────────
cp "$PERSIST_HTPASSWD" "$SHARED_HTPASSWD"
chmod 644 "$SHARED_HTPASSWD"
log "Gateway credential ready for user '$PORTAL_USER' ($SHARED_HTPASSWD)"

View File

@ -0,0 +1,17 @@
[Unit]
Description=Ansipa portal gateway Basic Auth credential setup
After=network.target docker-env.service
Wants=docker-env.service
[Service]
Type=oneshot
RemainAfterExit=yes
# Passwords 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-portal-auth-setup.sh
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target

View File

@ -40,6 +40,8 @@ volumes:
name: cmk-data name: cmk-data
cmk-creds: # shared: CheckMK writes automation.secret here; FreeIPA reads it cmk-creds: # shared: CheckMK writes automation.secret here; FreeIPA reads it
name: cmk-creds name: cmk-creds
portal-auth: # shared: FreeIPA writes the gateway htpasswd here; nginx reads it
name: portal-auth
networks: networks:
ipa-net: ipa-net:
@ -70,6 +72,7 @@ services:
- freeipa-data:/data - freeipa-data:/data
- /sys/fs/cgroup:/sys/fs/cgroup:rw - /sys/fs/cgroup:/sys/fs/cgroup:rw
- cmk-creds:/cmk-creds # read CMK secret + write push-mode requests for hosts - cmk-creds:/cmk-creds # read CMK secret + write push-mode requests for hosts
- portal-auth:/portal-auth # write the gateway htpasswd for nginx to read
environment: environment:
IPA_DOMAIN: ${IPA_DOMAIN:?set IPA_DOMAIN in .env} IPA_DOMAIN: ${IPA_DOMAIN:?set IPA_DOMAIN in .env}
IPA_REALM: ${IPA_REALM:-} IPA_REALM: ${IPA_REALM:-}
@ -83,6 +86,8 @@ 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_PORTAL_USER: ${ANSIPA_PORTAL_USER:-ansipa}
ANSIPA_PORTAL_PASSWORD: ${ANSIPA_PORTAL_PASSWORD:-}
ANSIPA_GIT_SSH_PORT: ${ANSIPA_GIT_SSH_PORT:-2222} ANSIPA_GIT_SSH_PORT: ${ANSIPA_GIT_SSH_PORT:-2222}
ANSIPA_GIT_ADMIN_PUBKEY: ${ANSIPA_GIT_ADMIN_PUBKEY:-} ANSIPA_GIT_ADMIN_PUBKEY: ${ANSIPA_GIT_ADMIN_PUBKEY:-}
ANSIPA_GIT_SIGNING_PUBKEY: ${ANSIPA_GIT_SIGNING_PUBKEY:-} ANSIPA_GIT_SIGNING_PUBKEY: ${ANSIPA_GIT_SIGNING_PUBKEY:-}
@ -271,6 +276,7 @@ services:
volumes: volumes:
- ./nginx/templates:/etc/nginx/templates:ro # *.template → envsubst → conf.d - ./nginx/templates:/etc/nginx/templates:ro # *.template → envsubst → conf.d
- ./nginx/portal:/usr/share/nginx/portal:ro - ./nginx/portal:/usr/share/nginx/portal:ro
- portal-auth:/etc/nginx/auth:ro # htpasswd written by the freeipa container
ports: ports:
- "${ANSIPA_HTTP_PORT:-8088}:80" - "${ANSIPA_HTTP_PORT:-8088}:80"
depends_on: depends_on:
@ -283,7 +289,10 @@ services:
# Use 127.0.0.1, not localhost: inside the container localhost resolves to # Use 127.0.0.1, not localhost: inside the container localhost resolves to
# IPv6 ::1, but nginx listens on IPv4 0.0.0.0:80 only, so a localhost probe # IPv6 ::1, but nginx listens on IPv4 0.0.0.0:80 only, so a localhost probe
# is refused even though the gateway is serving fine. # is refused even though the gateway is serving fine.
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/ || exit 1"] # 401 counts as healthy: the gateway now requires Basic Auth on every
# location (see ansipa-portal-auth-setup.sh) — an unauthenticated probe
# is SUPPOSED to get 401, that's proof the gate is up, not a fault.
test: ["CMD-SHELL", "wget -S -O /dev/null http://127.0.0.1/ 2>&1 | grep -qE 'HTTP/[0-9.]+ (200|401)'"]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 10 retries: 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 ansipa-git-server-setup.service Before=basic.target ipa-first-boot.service ansipa-smb.service ansipa-git-server-setup.service ansipa-portal-auth.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|ANSIPA_GIT)_' > /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|ANSIPA_PORTAL)_' > /etc/container.env; chmod 600 /etc/container.env"
[Install] [Install]
WantedBy=sysinit.target WantedBy=sysinit.target

View File

@ -33,6 +33,7 @@
<footer> <footer>
<p>Served by the ansipa nginx gateway. All services share this host and are safe to place behind a further reverse proxy.</p> <p>Served by the ansipa nginx gateway. All services share this host and are safe to place behind a further reverse proxy.</p>
<p>You just signed in to the gateway itself — each service below still asks for its own login too. That's intentional.</p>
</footer> </footer>
</main> </main>
</body> </body>

View File

@ -28,6 +28,17 @@ server {
proxy_buffers 8 32k; proxy_buffers 8 32k;
proxy_buffer_size 32k; proxy_buffer_size 32k;
# ── Gateway-wide sign-in (deliberate, in addition to each backend's own
# login) ────────────────────────────────────────────────────────────────
# Inherited by every location below — the portal AND every proxied web UI
# all sit behind this one shared credential first. FreeIPA / CheckMK /
# Keycloak still each ask for their own login afterwards: two prompts is
# the intended tradeoff, not a bug — see ansipa-portal-auth-setup.sh.
# File is populated by the freeipa container and shared via the
# 'portal-auth' volume; managed with ANSIPA_PORTAL_USER/_PASSWORD in .env.
auth_basic "ansipa gateway — sign in";
auth_basic_user_file /etc/nginx/auth/htpasswd;
# ── Portal start page ──────────────────────────────────────────────────── # ── Portal start page ────────────────────────────────────────────────────
location = / { location = / {
root /usr/share/nginx/portal; root /usr/share/nginx/portal;

View File

@ -52,6 +52,7 @@ start_freeipa() {
# dev_mon_* CheckMK integration silently never activates) # dev_mon_* CheckMK integration silently never activates)
docker volume create freeipa-data 2>/dev/null || true docker volume create freeipa-data 2>/dev/null || true
docker volume create cmk-creds 2>/dev/null || true docker volume create cmk-creds 2>/dev/null || true
docker volume create portal-auth 2>/dev/null || true
docker run -d \ docker run -d \
--name freeipa \ --name freeipa \
@ -63,6 +64,7 @@ start_freeipa() {
-v /sys/fs/cgroup:/sys/fs/cgroup:rw \ -v /sys/fs/cgroup:/sys/fs/cgroup:rw \
-v freeipa-data:/data \ -v freeipa-data:/data \
-v cmk-creds:/cmk-creds \ -v cmk-creds:/cmk-creds \
-v portal-auth:/portal-auth \
--network "$NETWORK" \ --network "$NETWORK" \
--ip 172.30.0.10 \ --ip 172.30.0.10 \
-e IPA_DOMAIN="${IPA_DOMAIN:?}" \ -e IPA_DOMAIN="${IPA_DOMAIN:?}" \
@ -79,6 +81,8 @@ start_freeipa() {
-e ANSIPA_GIT_SSH_PORT="${ANSIPA_GIT_SSH_PORT:-2222}" \ -e ANSIPA_GIT_SSH_PORT="${ANSIPA_GIT_SSH_PORT:-2222}" \
-e ANSIPA_GIT_ADMIN_PUBKEY="${ANSIPA_GIT_ADMIN_PUBKEY:-}" \ -e ANSIPA_GIT_ADMIN_PUBKEY="${ANSIPA_GIT_ADMIN_PUBKEY:-}" \
-e ANSIPA_GIT_SIGNING_PUBKEY="${ANSIPA_GIT_SIGNING_PUBKEY:-}" \ -e ANSIPA_GIT_SIGNING_PUBKEY="${ANSIPA_GIT_SIGNING_PUBKEY:-}" \
-e ANSIPA_PORTAL_USER="${ANSIPA_PORTAL_USER:-ansipa}" \
-e ANSIPA_PORTAL_PASSWORD="${ANSIPA_PORTAL_PASSWORD:-}" \
-p 389:389 \ -p 389:389 \
-p 636:636 \ -p 636:636 \
-p 88:88 \ -p 88:88 \