fix(web-app-pihole): add variants, javascript injection, fix oauth2 DNS, update playwright tests

This commit is contained in:
Prageeth Panicker authored and kevinveenbirkenbach committed 2026-06-24 03:46:09 +02:00
1 parent 2c1d0cbd28
commit 0c91412727
8 files changed
+128 -56

No files matched your search

+32 -52
View File
@@ -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.<your-domain>`
- 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.<DOMAIN_PRIMARY>/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
+46 -1
View File
@@ -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");
});
+1 -1
View File
@@ -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: |
+1 -1
View File
@@ -1,6 +1,6 @@
# noqa: shared
oauth2:
enabled: true
enabled: "{{ 'web-app-keycloak' in group_names }}"
shared: true
origin:
host: pihole
+9
View File
@@ -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
@@ -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 }}"
@@ -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 = '<i class="fa fa-sign-out"></i> 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,
});
})();
+1 -1
View File
@@ -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