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
parent
4200557757
commit
4730f89e82
|
|
@ -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.
|
||||
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
|
||||
|
|
@ -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
|
||||
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
|
||||
|
||||
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
|
||||
# server
|
||||
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)
|
||||
id admin # SSSD resolves IPA users
|
||||
kinit admin && klist # Kerberos works
|
||||
|
|
|
|||
|
|
@ -52,6 +52,16 @@ ANSIPA_GIT_ADMIN_PUBKEY=
|
|||
ANSIPA_GIT_SIGNING_PUBKEY=
|
||||
|
||||
# ── 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).
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ RUN dnf install -y --setopt=install_weak_deps=False \
|
|||
openssh-server \
|
||||
openssh-clients \
|
||||
gnupg2 \
|
||||
openssl \
|
||||
&& dnf clean all \
|
||||
&& 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-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-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 \
|
||||
&& 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 \
|
||||
&& chmod +x /usr/local/sbin/ansipa-portal-auth-setup.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 ansipa-portal-auth.service \
|
||||
&& systemctl enable smb.service nmb.service crond.service
|
||||
|
||||
VOLUME ["/data"]
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
|
|
@ -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
|
||||
|
|
@ -40,6 +40,8 @@ volumes:
|
|||
name: cmk-data
|
||||
cmk-creds: # shared: CheckMK writes automation.secret here; FreeIPA reads it
|
||||
name: cmk-creds
|
||||
portal-auth: # shared: FreeIPA writes the gateway htpasswd here; nginx reads it
|
||||
name: portal-auth
|
||||
|
||||
networks:
|
||||
ipa-net:
|
||||
|
|
@ -70,6 +72,7 @@ services:
|
|||
- freeipa-data:/data
|
||||
- /sys/fs/cgroup:/sys/fs/cgroup:rw
|
||||
- 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:
|
||||
IPA_DOMAIN: ${IPA_DOMAIN:?set IPA_DOMAIN in .env}
|
||||
IPA_REALM: ${IPA_REALM:-}
|
||||
|
|
@ -83,6 +86,8 @@ 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_PORTAL_USER: ${ANSIPA_PORTAL_USER:-ansipa}
|
||||
ANSIPA_PORTAL_PASSWORD: ${ANSIPA_PORTAL_PASSWORD:-}
|
||||
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:-}
|
||||
|
|
@ -271,6 +276,7 @@ services:
|
|||
volumes:
|
||||
- ./nginx/templates:/etc/nginx/templates:ro # *.template → envsubst → conf.d
|
||||
- ./nginx/portal:/usr/share/nginx/portal:ro
|
||||
- portal-auth:/etc/nginx/auth:ro # htpasswd written by the freeipa container
|
||||
ports:
|
||||
- "${ANSIPA_HTTP_PORT:-8088}:80"
|
||||
depends_on:
|
||||
|
|
@ -283,7 +289,10 @@ services:
|
|||
# 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
|
||||
# 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
|
||||
timeout: 10s
|
||||
retries: 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 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]
|
||||
Type=oneshot
|
||||
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]
|
||||
WantedBy=sysinit.target
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@
|
|||
|
||||
<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>You just signed in to the gateway itself — each service below still asks for its own login too. That's intentional.</p>
|
||||
</footer>
|
||||
</main>
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -28,6 +28,17 @@ server {
|
|||
proxy_buffers 8 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 ────────────────────────────────────────────────────
|
||||
location = / {
|
||||
root /usr/share/nginx/portal;
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ start_freeipa() {
|
|||
# dev_mon_* CheckMK integration silently never activates)
|
||||
docker volume create freeipa-data 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 \
|
||||
--name freeipa \
|
||||
|
|
@ -63,6 +64,7 @@ start_freeipa() {
|
|||
-v /sys/fs/cgroup:/sys/fs/cgroup:rw \
|
||||
-v freeipa-data:/data \
|
||||
-v cmk-creds:/cmk-creds \
|
||||
-v portal-auth:/portal-auth \
|
||||
--network "$NETWORK" \
|
||||
--ip 172.30.0.10 \
|
||||
-e IPA_DOMAIN="${IPA_DOMAIN:?}" \
|
||||
|
|
@ -79,6 +81,8 @@ start_freeipa() {
|
|||
-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:-}" \
|
||||
-e ANSIPA_PORTAL_USER="${ANSIPA_PORTAL_USER:-ansipa}" \
|
||||
-e ANSIPA_PORTAL_PASSWORD="${ANSIPA_PORTAL_PASSWORD:-}" \
|
||||
-p 389:389 \
|
||||
-p 636:636 \
|
||||
-p 88:88 \
|
||||
|
|
|
|||
Loading…
Reference in New Issue