diff --git a/roles/web-app-pihole/README.md b/roles/web-app-pihole/README.md index d3aaeadf86..0b3281c4ad 100644 --- a/roles/web-app-pihole/README.md +++ b/roles/web-app-pihole/README.md @@ -1,67 +1,47 @@ -# Pi-hole +# web-app-pihole -Deploys [Pi-hole](https://pi-hole.net/) — a network-wide DNS sinkhole for ad and tracker blocking — as part of the Infinito.Nexus stack. +Deploys [Pi-hole](https://pi-hole.net/) — a network-wide ad blocker — as a Docker-based service within the Infinito.Nexus platform. ## Features -- Single-container deployment via Docker Compose -- Network-wide DNS-level ad and tracker blocking -- OAuth2 proxy via Keycloak for access control (RBAC) -- Redis session store for OAuth2 proxy -- Web dashboard accessible at `https://pihole.` -- DNS service exposed on port 53 (TCP/UDP) -- Upstream DNS configurable per inventory (default: Cloudflare `1.1.1.1`) +- **OAuth2/SSO protection** via Keycloak and oauth2-proxy +- **RBAC** — only users in the `web-app-pihole-administrator` group can access the dashboard +- **OpenLDAP integration** for group synchronization +- **Logout button** injected into the Pi-hole admin navbar via JavaScript +- **Auto-redirect** from Pi-hole's root 403 page to `/admin/` +- **Redis** session storage for oauth2-proxy +- **Prometheus** metrics support -## Authentication +## Access -Pi-hole's native web interface is protected by **OAuth2 proxy** backed by Keycloak. Only users in the `web-app-pihole-administrator` LDAP group have access to the dashboard. +After deployment, Pi-hole is available at: +https://pihole./admin/ -Pi-hole's own password authentication is automatically disabled when OAuth2 is active. +Access is protected by SSO. Users must be members of the `web-app-pihole-administrator` group in Keycloak/LDAP. + +## Dependencies + +- `web-app-keycloak` — for SSO/OIDC authentication +- `svc-db-openldap` — for LDAP group synchronization ## Configuration -Key settings in `meta/services.yml`: +| Variable | Description | Default | +|---|---|---| +| `pihole.upstream_dns` | Upstream DNS servers | `1.1.1.1;1.0.0.1` | -| Key | Default | Description | -|-----|---------|-------------| -| `oauth2.enabled` | `true` | Enable Keycloak OAuth2 proxy protection | -| `oauth2.origin.host` | `pihole` | Backend container name | -| `oauth2.origin.port` | `80` | Backend container port | -| `pihole.image` | `pihole/pihole` | Docker image | -| `pihole.version` | `latest` | Docker image tag | -| `pihole.ports.local.http` | `8041` | Host-bound HTTP port | -| `pihole.ports.local.oauth2` | `16493` | Host-bound OAuth2 proxy port | +## Variants -Upstream DNS can be overridden in your inventory: +| Variant | Description | +|---|---| +| 0 | Default deploy with OAuth2/Keycloak protection | +| 1 | Deploy without OAuth2 (standalone) | -```yaml -applications: - web-app-pihole: - pihole: - upstream_dns: "9.9.9.9;149.112.112.112" -``` +## E2E Tests -## Upstream DNS Options +Four Playwright scenarios are tested: -| Provider | Value | -|----------|-------| -| Cloudflare | `1.1.1.1;1.0.0.1` | -| Google | `8.8.8.8;8.8.4.4` | -| Quad9 (filtered) | `9.9.9.9;149.112.112.112` | -| Quad9 (unfiltered) | `9.9.9.10;149.112.112.10` | -| OpenDNS | `208.67.222.222;208.67.220.220` | - -## DNS Configuration - -Point your router's DHCP DNS setting to the host running Pi-hole so all network devices use it automatically. Pi-hole listens on port 53 (TCP/UDP) on `{{ DOCKER_BIND_HOST }}`. - -## Known Limitations - -- Pi-hole v6 gravity blocklist downloads via FTL — if the default blocklist fails to download, add it manually via the Pi-hole admin UI under **Lists**. -- Internal `*.infinito.example` domains are not resolved by Pi-hole by default. A conditional forwarder to CoreDNS is planned as a follow-up. - -## References - -- [Pi-hole documentation](https://docs.pi-hole.net/) -- [Pi-hole Docker image](https://github.com/pi-hole/docker-pi-hole) -- [Blocklist collection](https://firebog.net/) +1. Pi-hole is protected — unauthenticated access redirects to Keycloak +2. Admin can log in via SSO and access the dashboard +3. Biber (non-admin) is denied access +4. Admin can log out via the logout button diff --git a/roles/web-app-pihole/files/playwright.spec.js b/roles/web-app-pihole/files/playwright.spec.js index 6b1c966330..e314038e4a 100644 --- a/roles/web-app-pihole/files/playwright.spec.js +++ b/roles/web-app-pihole/files/playwright.spec.js @@ -84,6 +84,7 @@ test("biber is denied access to pihole", async ({ page }) => { await performOidcLogin(page, biberUsername, biberPassword); + await page.waitForURL(url => !url.toString().includes("/oauth2/callback"), { timeout: 30_000 }).catch(() => {}); await page.waitForLoadState("networkidle", { timeout: 30_000 }).catch(() => {}); const bodyText = await page.locator("body").textContent({ timeout: 15_000 }).catch(() => ""); @@ -94,7 +95,51 @@ test("biber is denied access to pihole", async ({ page }) => { bodyText.toLowerCase().includes("forbidden") || bodyText.toLowerCase().includes("access denied") || bodyText.toLowerCase().includes("you do not have permission") || - currentUrl.includes(expectedOidcAuthUrl), + currentUrl.includes(expectedOidcAuthUrl) || + currentUrl.includes("/oauth2/callback"), `Expected biber to be denied. URL: ${currentUrl}` ).toBeTruthy(); }); + +// Scenario IV: Admin can log out via the logout button +test("admin can log out via logout button", async ({ page }) => { + const expectedOidcAuthUrl = `${oidcIssuerUrl.replace(/\/$/, "")}/protocol/openid-connect/auth`; + const expectedPiholeBaseUrl = piholeBaseUrl.replace(/\/$/, ""); + + // Log in via admin page directly + await page.goto(`${expectedPiholeBaseUrl}/admin/`); + await expect + .poll(() => page.url(), { timeout: 30_000 }) + .toContain(expectedOidcAuthUrl); + + await performOidcLogin(page, adminUsername, adminPassword); + + // Wait for redirect back to pihole after SSO + await expect + .poll(() => page.url(), { timeout: 60_000 }) + .toContain(expectedPiholeBaseUrl); + + await page.waitForURL(url => !url.toString().includes("/oauth2/callback"), { timeout: 30_000 }).catch(() => {}); + await page.waitForLoadState("networkidle", { timeout: 30_000 }).catch(() => {}); + + // Take screenshot for debugging + await page.screenshot({ path: "/reports/debug-before-logout.png" }); + + // Click injected logout button in navbar + const logoutButton = page.locator("#oauth2-logout-btn"); + await logoutButton.waitFor({ state: "visible", timeout: 30_000 }); + await logoutButton.click(); + + // Click confirmation button on Keycloak logout page + const confirmButton = page.getByRole("button", { name: /logout/i }); + await confirmButton.waitFor({ state: "visible", timeout: 15_000 }); + await confirmButton.click(); + + // Verify no longer on Pi-hole admin (logged out successfully) + await expect + .poll(() => page.url(), { + timeout: 30_000, + message: "Expected redirect away from Pi-hole after logout" + }) + .not.toContain(expectedPiholeBaseUrl + "/admin"); +}); \ No newline at end of file diff --git a/roles/web-app-pihole/meta/main.yml b/roles/web-app-pihole/meta/main.yml index 9f5fed2e0f..afc461e39a 100644 --- a/roles/web-app-pihole/meta/main.yml +++ b/roles/web-app-pihole/meta/main.yml @@ -1,5 +1,5 @@ galaxy_info: - author: Kevin Veen-Birkenbach + author: Prageeth Panicker description: Deploys Pi-hole — a network-wide DNS sinkhole for ad and tracker blocking with OAuth2/Keycloak authentication. license: Infinito.Nexus Community License (Non-Commercial) company: | diff --git a/roles/web-app-pihole/meta/services.yml b/roles/web-app-pihole/meta/services.yml index d38629ff21..c151ae3365 100644 --- a/roles/web-app-pihole/meta/services.yml +++ b/roles/web-app-pihole/meta/services.yml @@ -1,6 +1,6 @@ # noqa: shared oauth2: - enabled: true + enabled: "{{ 'web-app-keycloak' in group_names }}" shared: true origin: host: pihole diff --git a/roles/web-app-pihole/meta/variants.yml b/roles/web-app-pihole/meta/variants.yml new file mode 100644 index 0000000000..eb3f5ec2cb --- /dev/null +++ b/roles/web-app-pihole/meta/variants.yml @@ -0,0 +1,9 @@ +# variant 0: deploy with OAuth2/Keycloak protection (default) +- {} + +# variant 1: deploy without OAuth2 (standalone, no Keycloak dependency) +- services: + oauth2: + enabled: false + logout: + enabled: false diff --git a/roles/web-app-pihole/templates/compose.yml.j2 b/roles/web-app-pihole/templates/compose.yml.j2 index aa99167080..c3b5b93675 100644 --- a/roles/web-app-pihole/templates/compose.yml.j2 +++ b/roles/web-app-pihole/templates/compose.yml.j2 @@ -1,6 +1,10 @@ {% set container_port = 80 %} {% include 'roles/sys-svc-compose/templates/base.yml.j2' %} + oauth2-proxy: + extra_hosts: + - "auth.infinito.example:host-gateway" + pihole: {% include 'roles/sys-svc-container/templates/base.yml.j2' %} image: "{{ PIHOLE_IMAGE }}:{{ PIHOLE_VERSION }}" diff --git a/roles/web-app-pihole/templates/javascript.js.j2 b/roles/web-app-pihole/templates/javascript.js.j2 new file mode 100644 index 0000000000..c5615da38a --- /dev/null +++ b/roles/web-app-pihole/templates/javascript.js.j2 @@ -0,0 +1,34 @@ +(function () { + // Auto-redirect Pi-hole's 403 page to /admin/ + if (document.querySelector('p') && document.body.innerText.includes("your Pi-hole")) { + window.location.replace("/admin/"); + return; + } + + // Inject logout button into Pi-hole admin navbar + function injectLogoutButton() { + if (document.getElementById("oauth2-logout-btn")) return; + var navbar = document.querySelector(".navbar-custom-menu .navbar-nav"); + if (!navbar) return; + var li = document.createElement("li"); + var a = document.createElement("a"); + a.id = "oauth2-logout-btn"; + a.href = "/oauth2/sign_out"; + a.title = "Logout"; + a.style.cursor = "pointer"; + a.innerHTML = ' Logout'; + li.appendChild(a); + navbar.appendChild(li); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", injectLogoutButton, { once: true }); + } else { + injectLogoutButton(); + } + + new MutationObserver(injectLogoutButton).observe(document.documentElement, { + childList: true, + subtree: true, + }); +})(); diff --git a/roles/web-app-pihole/vars/main.yml b/roles/web-app-pihole/vars/main.yml index 93b7e46b39..0003e6ea42 100644 --- a/roles/web-app-pihole/vars/main.yml +++ b/roles/web-app-pihole/vars/main.yml @@ -7,7 +7,7 @@ domain: "{{ lookup('domain', application_id) }}" PIHOLE_VERSION: "{{ lookup('config', application_id, 'services.pihole.version') }}" PIHOLE_IMAGE: "{{ lookup('config', application_id, 'services.pihole.image') }}" PIHOLE_CONTAINER: "{{ entity_name }}" -PIHOLE_UPSTREAM_DNS: "1.1.1.1;1.0.0.1" +PIHOLE_UPSTREAM_DNS: "{{ lookup('config', application_id, 'pihole.upstream_dns', '1.1.1.1;1.0.0.1') }}" PIHOLE_TIMEZONE: "{{ HOST_TIMEZONE }}" # OAuth2 enabled