diff --git a/docs/md/ansipa-setup.md b/docs/md/ansipa-setup.md index d4ab2a0..97ea5df 100644 --- a/docs/md/ansipa-setup.md +++ b/docs/md/ansipa-setup.md @@ -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 diff --git a/setup/modules/FreeipaAnsible/image/.env.example b/setup/modules/FreeipaAnsible/image/.env.example index 3cc7f6a..b2ccde5 100644 --- a/setup/modules/FreeipaAnsible/image/.env.example +++ b/setup/modules/FreeipaAnsible/image/.env.example @@ -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://:${ANSIPA_HTTP_PORT}. The gateway fronts all UIs on one diff --git a/setup/modules/FreeipaAnsible/image/Dockerfile b/setup/modules/FreeipaAnsible/image/Dockerfile index 022c753..350b471 100644 --- a/setup/modules/FreeipaAnsible/image/Dockerfile +++ b/setup/modules/FreeipaAnsible/image/Dockerfile @@ -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"] diff --git a/setup/modules/FreeipaAnsible/image/ansipa-portal-auth-setup.sh b/setup/modules/FreeipaAnsible/image/ansipa-portal-auth-setup.sh new file mode 100755 index 0000000..c2b3b3f --- /dev/null +++ b/setup/modules/FreeipaAnsible/image/ansipa-portal-auth-setup.sh @@ -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)" diff --git a/setup/modules/FreeipaAnsible/image/ansipa-portal-auth.service b/setup/modules/FreeipaAnsible/image/ansipa-portal-auth.service new file mode 100644 index 0000000..a71c461 --- /dev/null +++ b/setup/modules/FreeipaAnsible/image/ansipa-portal-auth.service @@ -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 diff --git a/setup/modules/FreeipaAnsible/image/docker-compose.yml b/setup/modules/FreeipaAnsible/image/docker-compose.yml index 5bb32b8..5924dee 100644 --- a/setup/modules/FreeipaAnsible/image/docker-compose.yml +++ b/setup/modules/FreeipaAnsible/image/docker-compose.yml @@ -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://: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 diff --git a/setup/modules/FreeipaAnsible/image/docker-env.service b/setup/modules/FreeipaAnsible/image/docker-env.service index 4e98b7e..e38626a 100644 --- a/setup/modules/FreeipaAnsible/image/docker-env.service +++ b/setup/modules/FreeipaAnsible/image/docker-env.service @@ -1,12 +1,12 @@ [Unit] Description=Export Docker container env vars for systemd services DefaultDependencies=no -Before=basic.target ipa-first-boot.service ansipa-smb.service 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 diff --git a/setup/modules/FreeipaAnsible/image/nginx/portal/index.html b/setup/modules/FreeipaAnsible/image/nginx/portal/index.html index 46d4b0c..c7b9093 100644 --- a/setup/modules/FreeipaAnsible/image/nginx/portal/index.html +++ b/setup/modules/FreeipaAnsible/image/nginx/portal/index.html @@ -33,6 +33,7 @@ diff --git a/setup/modules/FreeipaAnsible/image/nginx/templates/default.conf.template b/setup/modules/FreeipaAnsible/image/nginx/templates/default.conf.template index 45ca735..2a2eb1e 100644 --- a/setup/modules/FreeipaAnsible/image/nginx/templates/default.conf.template +++ b/setup/modules/FreeipaAnsible/image/nginx/templates/default.conf.template @@ -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; diff --git a/setup/modules/FreeipaAnsible/image/run.sh b/setup/modules/FreeipaAnsible/image/run.sh index d3c1586..dcbd7b6 100755 --- a/setup/modules/FreeipaAnsible/image/run.sh +++ b/setup/modules/FreeipaAnsible/image/run.sh @@ -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 \