feat(ansipa): nginx gateway — reverse proxy + portal start-page

Add an nginx service that fronts all three web UIs on a single hostname/port
and serves a portal landing page, designed to sit behind the operator's own
TLS-terminating reverse proxy.

- Each backend is proxied at the path it already serves natively — FreeIPA
  /ipa, CheckMK /cmk, Keycloak under a configured /auth relative path — so no
  fragile path rewriting is needed and one upstream proxy line exposes
  everything. FreeIPA's strict Host/Referer anti-CSRF checks are satisfied by
  pinning Host to the IPA hostname and rewriting Referer.
- Honors incoming X-Forwarded-Proto/For so it works behind a second reverse
  proxy (double-proxy verified: portal, FreeIPA, CheckMK and Keycloak all
  reachable through two layers, and a CheckMK login POST completes to an
  authenticated session through the gateway).
- Keycloak: KC_HTTP_RELATIVE_PATH=/auth + KC_PROXY_HEADERS=xforwarded so it
  builds correct URLs behind the gateway; deliberately not forwarding
  X-Forwarded-Port (this gateway is :80 internally; the public port is the
  upstream proxy's) to avoid https://host:80 redirects.
- .env.example documents ANSIPA_HTTP_PORT and gives the exact upstream
  reverse-proxy snippet (nginx location block + Caddy one-liner).

Portal is a static cyberqueer-themed page linking to /ipa/ui/, /cmk/, /auth/.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUhrcFU8J1Hnf7vNqNxZNi
feat/astal-menu
Amir Alexander Abdelbaki 2026-07-02 12:36:28 +02:00
parent b21f415cde
commit be757fda5a
5 changed files with 219 additions and 0 deletions

View File

@ -35,6 +35,18 @@ CMK_ADMIN_PASSWORD=ChangeMe_CMK!
CMK_SITE_ID=cmk
CMK_ADVERTISED_URL=
# ── 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
# http://<docker-host>:${ANSIPA_HTTP_PORT}. The gateway fronts all UIs on one
# host: /ipa/ (FreeIPA), /cmk/ (CheckMK), /auth/ (Keycloak), / (portal).
# Example upstream nginx (in your main reverse proxy), to expose ansipa over HTTPS:
# location / { proxy_pass http://<docker-host>:8088; proxy_set_header Host $host;
# proxy_set_header X-Forwarded-Proto $scheme;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; }
# Or Caddy one-liner: ansipa.example.com { reverse_proxy <docker-host>:8088 }
ANSIPA_HTTP_PORT=8088
# ── Keycloak ──────────────────────────────────────────────────────────────────
KC_HOSTNAME=keycloak.corp.example.com
KC_REALM=corp

View File

@ -211,6 +211,14 @@ services:
KC_HTTPS_PORT: 8443
KC_HTTP_ENABLED: "true"
KC_HEALTH_ENABLED: "true" # exposes /health/* on management port 9000 (KC 25+)
# Serve Keycloak under /auth so the ansipa nginx gateway can proxy it at a
# native subpath (no rewriting). KC_PROXY_HEADERS=xforwarded makes Keycloak
# trust X-Forwarded-* so it builds correct absolute URLs behind the gateway
# (and behind a further upstream proxy). KC_HOSTNAME_STRICT=false keeps it
# from pinning to a single hostname during testing.
KC_HTTP_RELATIVE_PATH: /auth
KC_PROXY_HEADERS: xforwarded
KC_HOSTNAME_STRICT: "false"
KC_FEATURES: preview
KEYCLOAK_ADMIN: ${KC_ADMIN:-admin}
KEYCLOAK_ADMIN_PASSWORD: ${KC_ADMIN_PASSWORD:?set KC_ADMIN_PASSWORD in .env}
@ -234,3 +242,42 @@ services:
timeout: 10s
retries: 20
start_period: 90s
# ── nginx gateway: reverse proxy + portal start-page ─────────────────────────
# Single HTTP entry point that fronts all three web UIs on one hostname:
# / → portal start page (links to each service)
# /ipa/ → FreeIPA web UI (proxied to the freeipa container, 443)
# /cmk/ → CheckMK (proxied to the checkmk container, 5000)
# /auth/ → Keycloak (KC served under /auth relative path)
#
# It speaks plain HTTP and trusts X-Forwarded-* so it is meant to sit BEHIND
# the operator's own TLS-terminating reverse proxy (see .env ANSIPA_* vars and
# the README snippet for the one-line upstream config). Published on
# ANSIPA_HTTP_PORT for direct access/testing when no upstream proxy is used.
nginx:
image: nginx:alpine
container_name: ansipa-nginx
restart: unless-stopped
environment:
# Upstream container addresses on ipa-net (envsubst'd into the template).
ANSIPA_IPA_UPSTREAM: 172.30.0.10
ANSIPA_CMK_UPSTREAM: 172.30.0.12:5000
ANSIPA_KC_UPSTREAM: keycloak:8080
IPA_HOSTNAME: ${IPA_HOSTNAME:-ipa.example.com}
volumes:
- ./nginx/templates:/etc/nginx/templates:ro # *.template → envsubst → conf.d
- ./nginx/portal:/usr/share/nginx/portal:ro
ports:
- "${ANSIPA_HTTP_PORT:-8088}:80"
depends_on:
- freeipa
- checkmk
- keycloak
networks:
ipa-net:
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost/ >/dev/null 2>&1 || exit 1"]
interval: 30s
timeout: 10s
retries: 10
start_period: 20s

View File

@ -0,0 +1,39 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ansipa · service portal</title>
<link rel="stylesheet" href="/portal.css">
</head>
<body>
<main>
<header>
<h1>ansipa</h1>
<p class="sub">FreeIPA identity &middot; Ansible policy &middot; CheckMK monitoring &middot; Keycloak SSO</p>
</header>
<section class="grid">
<a class="card" href="/ipa/ui/">
<h2>FreeIPA</h2>
<p>Identity, hosts, groups, sudo &amp; HBAC rules — the policy source of truth.</p>
<span class="path">/ipa/ui/</span>
</a>
<a class="card" href="/cmk/">
<h2>CheckMK</h2>
<p>Fleet monitoring: agents, host status, ansipa dev_mon_* checks.</p>
<span class="path">/cmk/</span>
</a>
<a class="card" href="/auth/">
<h2>Keycloak</h2>
<p>Single sign-on and identity federation against FreeIPA LDAP.</p>
<span class="path">/auth/</span>
</a>
</section>
<footer>
<p>Served by the ansipa nginx gateway. All services share this host and are safe to place behind a further reverse proxy.</p>
</footer>
</main>
</body>
</html>

View File

@ -0,0 +1,43 @@
:root {
--bg: #0b0e14;
--fg: #d7dce5;
--muted: #8a93a6;
--card: #141925;
--accent: #c678dd;
--accent2: #56b6c2;
--border: #232a3a;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "JetBrains Mono", ui-monospace, "Cascadia Code", monospace;
background: radial-gradient(1200px 600px at 70% -10%, #17203a 0%, var(--bg) 60%);
color: var(--fg);
min-height: 100vh;
}
main { max-width: 960px; margin: 0 auto; padding: 4rem 1.5rem; }
header { text-align: center; margin-bottom: 3rem; }
h1 {
font-size: 3rem; margin: 0; letter-spacing: 0.15em;
background: linear-gradient(90deg, var(--accent), var(--accent2));
-webkit-background-clip: text; background-clip: text; color: transparent;
}
.sub { color: var(--muted); margin-top: 0.5rem; }
.grid {
display: grid; gap: 1.25rem;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
}
.card {
display: block; text-decoration: none; color: inherit;
background: var(--card); border: 1px solid var(--border);
border-radius: 12px; padding: 1.5rem;
transition: transform 0.12s ease, border-color 0.12s ease;
}
.card:hover { transform: translateY(-3px); border-color: var(--accent); }
.card h2 { margin: 0 0 0.5rem; color: var(--accent2); }
.card p { margin: 0 0 1rem; color: var(--muted); font-size: 0.9rem; line-height: 1.5; }
.path {
font-size: 0.8rem; color: var(--accent);
background: #0d1119; padding: 0.2rem 0.5rem; border-radius: 6px;
}
footer { margin-top: 3rem; text-align: center; color: var(--muted); font-size: 0.8rem; }

View File

@ -0,0 +1,78 @@
# ansipa reverse-proxy + portal — rendered by nginx's envsubst entrypoint.
# Placeholders (${VAR}) are filled from the container environment at start.
#
# Design: each backend is proxied at the path it ALREADY serves natively
# (FreeIPA /ipa, CheckMK /cmk, Keycloak under a configured /auth relative path),
# so no fragile path rewriting is needed and a single hostname/entry works —
# which is what lets the whole stack sit behind one upstream reverse proxy.
# Honor an upstream proxy's X-Forwarded-Proto if present (double-proxy case),
# otherwise fall back to this server's own scheme.
map $http_x_forwarded_proto $ansipa_fwd_proto {
default $scheme;
"~.+" $http_x_forwarded_proto;
}
# Preserve the original client IP chain when we sit behind another proxy.
map $http_x_forwarded_for $ansipa_fwd_for {
default $remote_addr;
"~.+" "$http_x_forwarded_for, $remote_addr";
}
server {
listen 80 default_server;
server_name _;
# Big cookies/headers: CheckMK and Keycloak set sizeable auth cookies.
large_client_header_buffers 4 32k;
proxy_busy_buffers_size 32k;
proxy_buffers 8 32k;
proxy_buffer_size 32k;
# ── Portal start page ────────────────────────────────────────────────────
location = / {
root /usr/share/nginx/portal;
try_files /index.html =404;
}
location = /index.html { root /usr/share/nginx/portal; }
location = /portal.css { root /usr/share/nginx/portal; }
# ── FreeIPA web UI (native path /ipa) ──────────────────────────────────────
# IPA's Apache is strict about Host and Referer (anti-CSRF): both must name
# the IPA server itself, not the proxy. Pin Host to the IPA hostname and
# rewrite Referer so the referer-check passes through the proxy.
location /ipa/ {
proxy_pass https://${ANSIPA_IPA_UPSTREAM}/ipa/;
proxy_ssl_server_name on;
proxy_ssl_verify off; # IPA ships a self-signed CA
proxy_set_header Host ${IPA_HOSTNAME};
proxy_set_header Referer https://${IPA_HOSTNAME}/ipa/ui/;
proxy_set_header X-Forwarded-Proto $ansipa_fwd_proto;
proxy_set_header X-Forwarded-For $ansipa_fwd_for;
proxy_set_header X-Forwarded-Host $host;
# IPA issues 301s to its own hostname; rewrite them back onto this proxy.
proxy_redirect https://${IPA_HOSTNAME}/ /ipa/../;
proxy_redirect http://${IPA_HOSTNAME}/ /ipa/../;
}
# ── CheckMK (native path /cmk) ─────────────────────────────────────────────
location /cmk/ {
proxy_pass http://${ANSIPA_CMK_UPSTREAM}/cmk/;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $ansipa_fwd_proto;
proxy_set_header X-Forwarded-For $ansipa_fwd_for;
proxy_set_header X-Forwarded-Host $host;
}
# ── Keycloak (relative path /auth, set via KC_HTTP_RELATIVE_PATH) ──────────
location /auth/ {
proxy_pass http://${ANSIPA_KC_UPSTREAM}/auth/;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $ansipa_fwd_proto;
proxy_set_header X-Forwarded-For $ansipa_fwd_for;
proxy_set_header X-Forwarded-Host $host;
# Deliberately NOT forwarding X-Forwarded-Port: this gateway listens on
# :80 internally, but the public port is whatever the upstream TLS proxy
# uses (typically 443). Sending :80 makes Keycloak emit redirects like
# https://host:80/... — let it infer the standard port from the proto.
}
}