⚠️ Vibe Code Disclosure This project was generated almost entirely through AI-assisted development (Claude Code / Anthropic). The code has been reviewed and iterated on collaboratively, but it has not been audited for production use. Deploy on trusted local networks only, review the code before relying on it, and proceed with the usual amount of healthy scepticism you'd apply to any AI-generated codebase.Independent reviews. Two LLM-based audits have been applied as part of the
hardening-reviewbranch — by Google Gemini (adversarial security review) and Grok (full architecture + security review). Both are documented in Independent reviews below, along with what was verified as already-correct and what was changed in response. These reviews are not a substitute for a professional audit.
A self-hosted dashboard that consolidates up to 10 locally running Pi-hole v6 instances into a single screen, paired with a REST API designed for iOS app consumption.
- Unified stat cards — Total queries, blocked count, % blocked, and domains on blocklist aggregated across all instances; blocklist card turns red if instances disagree
- Stat card footer links — each card has a Pi-hole–style footer: unique client count (→ Unique Clients view), List blocked queries, List all queries, Manage lists (→ master Pi-hole admin in new tab)
- Time range selector — Last 15 minutes, Last 1 hour, Today, Last 24 hours (default), 48 hours, 7 days, 30 days; chart bucket granularity scales automatically with the window
- DNS Queries over Time chart — configurable bucket size per time range, from actual query log data (not cumulative counters)
- Query type breakdown — Doughnut chart (Forwarded / Cached / Blocked / Other)
- Per-system panel — Each Pi-hole shown individually with its own stats and online/offline badge
- Top Permitted Domains, Top Blocked Domains, Top Clients — Clickable rows open a drill-down modal with all matching queries for that domain or client
- Blocked by List — ranks the window's blocks by the source blocklist that caught them; lists in a designated Pi-hole security group (for dedicated malware/phishing feeds like HaGeZi TIF or URLhaus) are flagged with a red "threat" badge, so security blocks stand out from ad/tracker noise. Configurable via
SECURITY_GROUP_NAME. Attribution is resolved live via Pi-hole's/api/search(Pi-hole's per-querylist_iddoesn't attribute gravity blocks), and cached per scope. - Which-list drill-down — click any blocked domain (in the query log or the Top Blocked table) to see exactly which adlist(s) block it: list name, source URL, security flag, and how often it was blocked in the window. The manage-domain (shield) modal shows the same attribution next to its allow/deny controls.
- Clickable MyPi logo in the sidebar — navigates to the dashboard from any other page; on the dashboard itself it refreshes the data in place without a full page reload
- Site name in page titles — on multi-site deployments, per-slug pages show the current site name in both the browser tab title and the in-page heading (e.g.
Dashboard: WTR)
Pi-hole v6's query API does not record which adlist caught a gravity block — the per-query list_id is null (or a negative sentinel) for gravity blocks, so it can't be joined back to a list. MyPi therefore resolves attribution on demand: it calls the relevant Pi-hole master's live GET /api/search/{domain}, which looks the domain up against gravity plus the exact/regex domainlist and returns the matching adlist(s).
- List names come from each adlist's Pi-hole
comment. Set a short name on your lists in the Pi-hole admin UI (Lists → comment, e.g.HaGeZi Pro,StevenBlack) and MyPi uses it everywhere a list is shown. A list with no comment falls back to a label derived from its source URL. - Where attribution appears: the which-list drill-down (click a blocked domain), the Blocked by List dashboard card (attributes the window's busiest blocked domains via
/api/searchand sums by list — cached per scope so a left-open dashboard doesn't hammer the master), and the manage-domain modal. - Security flag: an adlist assigned to the Pi-hole group named by
SECURITY_GROUP_NAMEis badged as a threat feed. - Scope: on a per-site page the search runs against that site's master; on the all-sites pages it uses any active master (gravity lists are typically identical across a household). No extra columns or backfill are stored — attribution is always live and current.
- Shown only when ≥2 active sites are configured. Aggregates every active Pi-hole instance across every active site into a single screen.
- Same four headline stat cards summed across sites; single aggregate DNS Queries over Time chart; Query Types pie.
- Pi-hole Systems table lists every instance across all sites with a colored site pill, so you can see which instance belongs to which site at a glance.
- Top Permitted / Top Blocked panels — merged by domain across sites (Top Clients is intentionally omitted because IP collisions between sites refer to different physical machines).
- Live Activity ticker — the 15 most recent queries across all sites, color-tagged by site, new rows animated in every ~3 seconds.
- Read-only: actions (sync, instance enable/disable, etc.) remain on the per-site pages.
- Consolidated query log across all instances with instance badge per row
- Column sorting (click any header), pagination, and a Live View toggle that refreshes every 2 seconds
- Filter by instance, domain, client, blocked/permitted status, and time range
- Block / Unblock domains — every row has an inline button: blocked-status queries show Unblock, all others show Block. One click adds or removes the domain from the master Pi-hole's exact deny list and triggers a gravity sync to all replicas in the background. The button toggles in-place without a page reload.
- Unique Clients view — Show dropdown option that switches to a per-client aggregate table (total queries, blocked count, % blocked, last seen); accessible directly from the dashboard stat card
- Deep-link URL params:
/queries?blocked=true,/queries?blocked=false,/queries?show=clientspre-set the Show filter automatically - Last-updated timestamp shown in the topbar after each refresh
- Push full configuration from a designated master Pi-hole to all replicas via the Pi-hole v6 teleporter API
- Sync order: master runs gravity first (fetches fresh blocklists) → exports teleporter zip → replicas import → replicas run gravity
- Per-key exclusions — pin settings each replica keeps as its own (local DNS records, CNAMEs, upstreams, DHCP range …) so a config sync can't replace them with the master's
- Selectable import options: configuration settings, gravity (adlists/blocklists/domains/clients), DHCP leases
- Configurable automatic sync interval: 15 min / 30 min / 1 hr / 6 hr / 24 hr, or manual-only
- Auto-sync on gravity change — detects when the master's blocklist count changes and triggers an immediate sync
- Schedule and last sync result persist across container restarts (stored in PostgreSQL)
- Dashboard shows "Pi synced: <time>" whenever a sync has run; time turns red if last sync was more than 24 hours ago
- Push alerts to any device via Pushover (iOS, Android, desktop)
- Configurable alerts: sync failure, instance offline/back online, high block rate, VIP transfer
- Stalled-state alerts — fires when a Pi-hole's admin API still answers but its query log has stopped advancing (the "split-state" failure that can follow a Pi-hole upgrade where FTL comes back half-up). Suggests
systemctl restart pihole-FTLin the alert body. Suppressed for instances markedvip_master/vip_replica(idle is normal on a standby) — replaced by a single group-level alert that fires only if every node in the cluster goes flat at once. - VIP transfer alerts — when the active node in a
vip_master/vip_replicacluster shifts, MyPi fires a "now serving from X (was Y)" notification. Off by default; opt in under Settings → Pushover. - High block rate alert requires ≥7 days of data to establish a baseline before firing
- No-logs and block-rate thresholds are configurable in Settings
- Credentials (App Token + User Key) stored encrypted in PostgreSQL, survive restarts
- Validate credentials and send a test notification directly from the Settings page
- Appearance — Light / Dark / System theme selector; preference stored in the browser and applied before first paint (no theme flash); Dark mode covers all UI surfaces including charts
- API key management (create / revoke) for iOS app authentication
- Instance list showing all active Pi-hole instances with online/offline badge, master indicator, and clickable URL links (open Pi-hole web UI in new tab)
- Orphaned instance cleanup — when an instance is renamed or removed from
pihole_instances.yml, the old record is detected and shown with an option to permanently remove it along with all associated stats and query log data, individually or in bulk - Software versions — Pi-hole (core), FTL, and web interface versions shown as columns in the instances table; fetched on each stats poll and persisted to the database so they survive restarts; color-coded green (up to date) or red (update available)
- Sync panel: import options, per-key keep-local exclusions, schedule configuration, live sync result with per-replica status
- Session Timeout panel: configure how long the web UI session stays active
- Pushover panel: credentials, master enable toggle, per-alert toggles, thresholds
- Version Check panel — shows running vs latest version, last check time; version badge in topbar turns green/red; checks GitHub once per hour (can be disabled)
- Full REST API under
/api/with auto-generated OpenAPI docs (Swagger UI at/docs, ReDoc at/redoc— opt-in viaENABLE_API_DOCS=true) - Username/password login for the web UI (JWT session cookie)
- API key auth (
X-API-Keyheader) for mobile clients and automation - Read-only API keys — mark a key read-only on creation; the
require_mutationdependency rejects it from every mutation endpoint with403. Session cookies and bearer JWTs are always full-access - Version badge in topbar is green (up to date) or red (update available), links to GitHub releases
- Security headers on every response —
X-Content-Type-Options: nosniff,X-Frame-Options: DENY,Referrer-Policy: same-origin, a Content-Security-Policy with no'unsafe-inline'for scripts or styles (same-origin + jsdelivr only;/docs//redocrelax style-src for Swagger UI/ReDoc), andStrict-Transport-SecuritywhenSECURE_COOKIES=true - Rate limits on mutation endpoints —
/api/sync(10/min),/api/domains/{deny,allow}(30/min each),/api/notifications/test(5/min),/api/notifications/validate(10/min),/api/auth/change-password(5/min) - Audit logging on mutations — every mutation handler logs
user=<username>plus the action and target, so the application log doubles as an audit trail - Encrypted secrets at rest — Pi-hole API passwords and Pushover credentials are Fernet-encrypted in PostgreSQL using
ENCRYPTION_KEY. Honest threat model: whenENCRYPTION_KEYis unset, the auto-generated key is stored in the same database (app_settings) as the ciphertext, so a database dump or backup contains both — the encryption then defends against nothing beyond casual observation. It only protects against DB/backup exposure once the key lives outside the data store: rundocker compose exec app python scripts/rotate_encryption_key.pyto generate a fresh key, re-encrypt stored secrets, remove the DB copy, and get the value to pin in.env. Treat pre-rotation backups as containing plaintext passwords. - Optional split secrets —
JWT_SECRET_KEYandAPI_KEY_SALTlet you rotate JWT signing keys without invalidating API keys (or vice versa); both fall back toSECRET_KEYwhen unset - Per-instance circuit breaker — a flap-prone Pi-hole is absorbed locally: after N consecutive connection failures, polling for that instance is suspended for a cooldown window, then one probe either closes the breaker or re-arms it. Defaults: 3 failures, 300 s cooldown, 2 s dedup (stats+queries share one connection). Tunable via
CIRCUIT_FAIL_THRESHOLD/CIRCUIT_COOLDOWN_SECONDS/CIRCUIT_DEDUP_SECONDS - Container runs as non-root (UID 1000, no shell); image ships with a
HEALTHCHECKagainst/api/health - Dependabot on a weekly schedule covers
pip,github-actions, anddockerecosystems
┌─────────────────────────────────────────────────┐
│ Pi-hole 1 Pi-hole 2 … Pi-hole N │
│ (Pi-hole v6, local network) │
└────────────────┬────────────────────────────────┘
│ HTTP or HTTPS (Pi-hole v6 REST API)
▼
┌────────────────────────────────────────────────────────┐
│ MyPi (Docker) │
│ │
│ ┌─────────────┐ ┌────────────────────────────────┐ │
│ │ APScheduler│ │ FastAPI │ │
│ │ poll stats │──▶│ • Web UI (Jinja2 / Bootstrap) │ │
│ │ poll queries │ • REST API (/api/*) │ │
│ │ sync service └──────────────┬────────────────┘ │
│ └─────────────┘ │ │
│ ┌──────────────▼──────────────┐ │
│ │ PostgreSQL 18 │ │
│ │ stats · queries · users │ │
│ │ settings · sync schedule │ │
│ └─────────────────────────────┘ │
└────────────────────────────────────────────────────────┘
│ REST API (JWT / API key)
▼
iOS App (coming soon)
| Layer | Technology |
|---|---|
| Web / API framework | FastAPI + Uvicorn |
| Database | PostgreSQL 18 |
| ORM / migrations | SQLAlchemy 2.0 async + Alembic |
| Background polling | APScheduler (in-process) |
| HTTP client | httpx (async) |
| Auth | python-jose (JWT) + passlib (bcrypt) |
| Frontend | Jinja2 + Bootstrap 5 + Chart.js |
| Config | PyYAML + pydantic-settings |
- Docker + Docker Compose
- Pi-hole v6 instances running on your local network
No need to clone the repository. The app is published as a pre-built image on the GitHub Container Registry.
mkdir mypi && cd mypi
# Docker Compose
curl -fsSL https://raw.githubusercontent.com/theojamesvibes/mypi/main/docker-compose.yml -o docker-compose.yml
# Environment variables template
curl -fsSL https://raw.githubusercontent.com/theojamesvibes/mypi/main/.env.example -o .env
# Pi-hole instances template
curl -fsSL https://raw.githubusercontent.com/theojamesvibes/mypi/main/pihole_instances.yml.example -o pihole_instances.ymlEdit .env — at minimum set these two values:
POSTGRES_PASSWORD=change-me
SECRET_KEY=$(python3 -c "import secrets; print(secrets.token_hex(32))")Edit pihole_instances.yml with your Pi-hole URLs and passwords (see Configuration below). Instances with no password set are supported — leave password empty or omit it.
docker compose up -dImportant — stop timeouts: The provided
docker-compose.ymlsetsstop_grace_periodon both services (60sfor Postgres,30sfor the app). Do not remove these. Without them Docker sendsSIGKILLafter only 10 seconds, which can interrupt a Postgres checkpoint mid-write and corrupt the database — requiring a table rebuild to recover.
Important — passing environment variables to the container: Compose's
.envfile is used for${VAR}substitution insidedocker-compose.yml, not automatically injected into the container. The security-relevant vars (SECURE_COOKIES,VERIFY_PIHOLE_SSL,ENABLE_API_DOCS,JWT_SECRET_KEY,API_KEY_SALT,ENCRYPTION_KEY) must either be listed explicitly underenvironment:or passed through withenv_file:. If you customise the compose file, the safest pattern is:app: image: ghcr.io/theojamesvibes/mypi:latest env_file: - .env # pass every var from .env into the container environment: # keep only values that need compose-level substitution or validation DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-mypi}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-mypi} SECRET_KEY: ${SECRET_KEY:?SECRET_KEY is required} PIHOLE_CONFIG_PATH: /app/pihole_instances.ymlWithout
env_file:, settingSECURE_COOKIES=truein.envhas no effect — cookies will be issued without theSecureflag and HSTS will not be sent, even behind a TLS reverse proxy.
Docker pulls the pre-built image automatically. The dashboard is available at http://localhost:8080 (or whichever APP_PORT you set in .env).
Log in with username admin (or INITIAL_ADMIN_USER if overridden). On first startup MyPi generates a random password for the admin account and prints it once to the container logs — retrieve it with:
docker compose logs app | grep -A2 "generated password"You will be required to set a new password immediately before accessing the dashboard. To pin a known password instead of using the generated one, set INITIAL_ADMIN_PASSWORD in .env before the first startup (a password change is still forced on first login).
git clone https://github.com/theojamesvibes/mypi.git && cd mypi
cp .env.example .env && cp pihole_instances.yml.example pihole_instances.yml
# edit both files, then:
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d| Variable | Default | Description |
|---|---|---|
POSTGRES_PASSWORD |
(required) | PostgreSQL password |
SECRET_KEY |
(required) | JWT signing secret — generate with secrets.token_hex(32) |
INITIAL_ADMIN_USER |
admin |
Username for the first admin account (created only on first startup, when no users exist yet) |
INITIAL_ADMIN_PASSWORD |
(random, logged Scan report · 2026-10-05
From the balcony · 0 of 3 clappedSchnitzel, Cap'm Slop and Princess read it and passed. Their reasons are on the balcony, with every other verdict. Critics are accounts on this site with no GitHub account behind them. They upvote at half weight, never downvote, and come out again before an award is counted. Who they are. report this listing— log in to report |
0 comments
log in to comment.