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_01MUhrcFU8J1Hnf7vNqNxZNifeat/astal-menu
parent
b21f415cde
commit
be757fda5a
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 · Ansible policy · CheckMK monitoring · Keycloak SSO</p>
|
||||
</header>
|
||||
|
||||
<section class="grid">
|
||||
<a class="card" href="/ipa/ui/">
|
||||
<h2>FreeIPA</h2>
|
||||
<p>Identity, hosts, groups, sudo & 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>
|
||||
|
|
@ -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; }
|
||||
|
|
@ -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.
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue