From 5cc5b5f5697a06942426587427b1086bc1cb51bc Mon Sep 17 00:00:00 2001 From: Fizzlepoof Date: Fri, 15 May 2026 03:17:56 +0000 Subject: [PATCH] Scaffold PD Pi-hole primary stack --- .gitignore | 3 ++ docs/planning/DEPLOY_PIHOLE.md | 19 ++++---- docs/planning/TODO.md | 1 + pihole/.env.example | 18 ++++++++ pihole/README.md | 47 +++++++++++++++++++ pihole/bin/prepare_pd.sh | 57 +++++++++++++++++++++++ pihole/bin/render_keepalived_conf.py | 67 ++++++++++++++++++++++++++++ pihole/docker-compose.yaml | 59 ++++++++++++++++++++++++ pihole/keepalived.conf.template | 32 +++++++++++++ pihole/unbound.conf | 27 +++++++++++ 10 files changed, 322 insertions(+), 8 deletions(-) create mode 100644 pihole/.env.example create mode 100644 pihole/README.md create mode 100755 pihole/bin/prepare_pd.sh create mode 100755 pihole/bin/render_keepalived_conf.py create mode 100644 pihole/docker-compose.yaml create mode 100644 pihole/keepalived.conf.template create mode 100644 pihole/unbound.conf diff --git a/.gitignore b/.gitignore index 0ddc204..3208017 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,9 @@ __pycache__/ # Temp scripts update-homelab-docs.py +# Generated stack config +pihole/keepalived.conf + # Editor .vscode/ .idea/ diff --git a/docs/planning/DEPLOY_PIHOLE.md b/docs/planning/DEPLOY_PIHOLE.md index d7a690b..10bbc65 100644 --- a/docs/planning/DEPLOY_PIHOLE.md +++ b/docs/planning/DEPLOY_PIHOLE.md @@ -1,6 +1,6 @@ # Pi-hole Deployment Plan -**Status:** Pending +**Status:** PD primary stack scaffolded repo-side at `pihole/`; live 3-node rollout still pending replica details and PD-side deployment verification **Architecture:** 3-node HA cluster with Keepalived VIP, Unbound recursive DNS, Nebula Sync --- @@ -108,7 +108,8 @@ mkdir -p /path/to/appdata/unbound/var ## Node 1: PlausibleDeniability (MASTER) -**Stack dir:** `/mnt/docker-ssd/docker/compose/pihole/` +**Repo stack dir:** `/home/fizzlepoof/repos/truenas-stacks/pihole/` +**Live stack dir:** `/mnt/docker-ssd/docker/compose/pihole/` **Web UI port:** `8953` ### Pre-Deploy @@ -210,14 +211,16 @@ LAN_INTERFACE=eno1 ### Deploy +Preferred PD-first flow now that the repo-side stack exists: + ```bash cd /mnt/docker-ssd/docker/compose/pihole -# Copy unbound.conf to appdata -sudo cp unbound.conf /mnt/tank/docker/appdata/unbound-primary/ -sudo docker compose --env-file .env config -sudo docker compose --env-file .env up -d -sudo docker logs pihole --tail 20 -sudo docker logs unbound --tail 20 +cp .env.example .env +nano .env +/usr/bin/bash bin/prepare_pd.sh +/usr/bin/bash bin/prepare_pd.sh --up +sudo docker logs pihole-primary --tail 20 +sudo docker logs unbound-pihole-primary --tail 20 ``` --- diff --git a/docs/planning/TODO.md b/docs/planning/TODO.md index 1aeee96..fad533b 100644 --- a/docs/planning/TODO.md +++ b/docs/planning/TODO.md @@ -6,6 +6,7 @@ ## Infrastructure - [ ] Deploy Pi-hole 3-node HA cluster (PD + Serenity + RPi4) — see docs/planning/DEPLOY_PIHOLE.md + - PD primary stack scaffolded repo-side under `pihole/` on 2026-05-15 - RPi4 IP still TBD — fill in before deploying - [x] Deploy Kima-Hub — completed 2026-05-06; docs/planning/DEPLOY_KIMA_HUB.md now serves as deployment/repair reference - [ ] Set up off-site backup for vital data (appdata, databases, config repo, photos) diff --git a/pihole/.env.example b/pihole/.env.example new file mode 100644 index 0000000..e7b9ba0 --- /dev/null +++ b/pihole/.env.example @@ -0,0 +1,18 @@ +TZ=America/Chicago + +# PD primary Pi-hole / Unbound paths +APPDATA_PIHOLE_ROOT=/mnt/tank/docker/appdata/pihole-primary +APPDATA_UNBOUND_ROOT=/mnt/tank/docker/appdata/unbound-primary + +# Pi-hole web/admin +PD_PIHOLE_WEB_PORT=8953 +PIHOLE_PASSWORD=CHANGE_ME + +# Keepalived / VIP (VRRP PASS auth is max 8 chars) +LAN_INTERFACE=eno1 +VIP_CIDR=10.5.1.53/24 +KEEPALIVED_ROUTER_ID=53 +KEEPALIVED_STATE=MASTER +KEEPALIVED_PRIORITY=100 +KEEPALIVED_PASSWORD=CHANGE01 +KEEPALIVED_NOPREEMPT=true diff --git a/pihole/README.md b/pihole/README.md new file mode 100644 index 0000000..8d19892 --- /dev/null +++ b/pihole/README.md @@ -0,0 +1,47 @@ +# PD Pi-hole / Unbound / Keepalived Stack + +Primary-node stack for the Pi-hole HA plan. + +- **Repo stack path:** `/home/fizzlepoof/repos/truenas-stacks/pihole` +- **Live PD stack path:** `/mnt/docker-ssd/docker/compose/pihole` +- **Node role:** PD primary / MASTER +- **VIP target:** `10.5.1.53/24` + +This stack intentionally covers the **PD primary** only: +- Pi-hole +- Unbound +- Keepalived + +Serenity and RPi4 replicas still need their own deployment steps. + +## Files + +- `docker-compose.yaml` — primary-node stack +- `.env.example` — required variables +- `unbound.conf` — shared recursive resolver config +- `keepalived.conf.template` — rendered into `keepalived.conf` +- `bin/render_keepalived_conf.py` — generates `keepalived.conf` from `.env` +- `bin/prepare_pd.sh` — creates appdata dirs, downloads root hints, renders config, validates compose, optional `--up` + +## First-time setup on PD + +```bash +cd /mnt/docker-ssd/docker/compose/pihole +cp .env.example .env +nano .env # set real passwords + verify LAN_INTERFACE +/usr/bin/bash bin/prepare_pd.sh +``` + +If that validates cleanly, bring it up: + +```bash +/usr/bin/bash bin/prepare_pd.sh --up +``` + +## Important notes + +- `KEEPALIVED_PASSWORD` must be **8 chars or fewer** because VRRP PASS auth is annoying and old. +- Verify `LAN_INTERFACE` on PD before deployment (`ip link show`). +- `FTLCONF_dns_listeningMode=LOCAL` is set because TrueNAS binds loopback on port 53. +- This stack does **not** include Nebula Sync yet. Add that after Serenity/RPi replicas are live. +- `keepalived.conf` is generated and should stay local/untracked. diff --git a/pihole/bin/prepare_pd.sh b/pihole/bin/prepare_pd.sh new file mode 100755 index 0000000..15b442c --- /dev/null +++ b/pihole/bin/prepare_pd.sh @@ -0,0 +1,57 @@ +#!/usr/bin/bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +STACK_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +ENV_FILE="${ENV_FILE:-$STACK_DIR/.env}" +DO_UP="${1:-}" + +if [[ ! -f "$ENV_FILE" ]]; then + echo "Missing $ENV_FILE — copy .env.example to .env and fill in real values first." >&2 + exit 1 +fi + +set -a +# shellcheck disable=SC1090 +source "$ENV_FILE" +set +a + +required_vars=( + APPDATA_PIHOLE_ROOT + APPDATA_UNBOUND_ROOT + LAN_INTERFACE + VIP_CIDR + KEEPALIVED_PASSWORD + PD_PIHOLE_WEB_PORT +) +for var in "${required_vars[@]}"; do + if [[ -z "${!var:-}" ]]; then + echo "Required variable $var is empty in $ENV_FILE" >&2 + exit 1 + fi +done + +mkdir -p \ + "$APPDATA_PIHOLE_ROOT/etc-pihole" \ + "$APPDATA_PIHOLE_ROOT/etc-dnsmasq.d" \ + "$APPDATA_UNBOUND_ROOT/var" + +cp "$STACK_DIR/unbound.conf" "$APPDATA_UNBOUND_ROOT/unbound.conf" + +if [[ ! -s "$APPDATA_UNBOUND_ROOT/var/root.hints" ]]; then + curl -fsSL -o "$APPDATA_UNBOUND_ROOT/var/root.hints" https://www.internic.net/domain/named.cache +fi + +ENV_FILE="$ENV_FILE" python3 "$STACK_DIR/bin/render_keepalived_conf.py" + +docker compose --env-file "$ENV_FILE" -f "$STACK_DIR/docker-compose.yaml" config >/dev/null + +echo "PD Pi-hole stack prepared at $STACK_DIR" +echo "- unbound config copied to $APPDATA_UNBOUND_ROOT/unbound.conf" +echo "- root hints present at $APPDATA_UNBOUND_ROOT/var/root.hints" +echo "- keepalived.conf rendered" + +if [[ "$DO_UP" == "--up" ]]; then + docker compose --env-file "$ENV_FILE" -f "$STACK_DIR/docker-compose.yaml" up -d + docker compose --env-file "$ENV_FILE" -f "$STACK_DIR/docker-compose.yaml" ps +fi diff --git a/pihole/bin/render_keepalived_conf.py b/pihole/bin/render_keepalived_conf.py new file mode 100755 index 0000000..156aab7 --- /dev/null +++ b/pihole/bin/render_keepalived_conf.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +from __future__ import annotations +import os +import re +import sys +from pathlib import Path + +STACK_DIR = Path(__file__).resolve().parent.parent +ENV_FILE = Path(os.environ.get('ENV_FILE', STACK_DIR / '.env')) +TEMPLATE = STACK_DIR / 'keepalived.conf.template' +OUTPUT = STACK_DIR / 'keepalived.conf' + + +def load_env(path: Path) -> dict[str, str]: + env = dict(os.environ) + if path.exists(): + for raw in path.read_text(encoding='utf-8').splitlines(): + line = raw.strip() + if not line or line.startswith('#') or '=' not in line: + continue + k, v = line.split('=', 1) + env.setdefault(k.strip(), v.strip().strip('"').strip("'")) + return env + + +def main() -> int: + env = load_env(ENV_FILE) + required = [ + 'LAN_INTERFACE', 'VIP_CIDR', 'KEEPALIVED_ROUTER_ID', 'KEEPALIVED_STATE', + 'KEEPALIVED_PRIORITY', 'KEEPALIVED_PASSWORD', 'PD_PIHOLE_WEB_PORT' + ] + missing = [k for k in required if not env.get(k)] + if missing: + print(f'Missing required vars in {ENV_FILE}: {", ".join(missing)}', file=sys.stderr) + return 1 + + keepalived_password = env['KEEPALIVED_PASSWORD'] + if len(keepalived_password) > 8: + print('KEEPALIVED_PASSWORD must be 8 characters or fewer for VRRP PASS auth.', file=sys.stderr) + return 1 + + try: + web_port_hex = f"{int(env['PD_PIHOLE_WEB_PORT']):04X}" + except ValueError: + print('PD_PIHOLE_WEB_PORT must be an integer.', file=sys.stderr) + return 1 + + router_id_name = f"pihole_{re.sub(r'[^a-z0-9]+', '_', env['KEEPALIVED_STATE'].lower())}" + nopreempt_line = ' nopreempt' if env.get('KEEPALIVED_NOPREEMPT', 'true').lower() in {'1','true','yes','on'} else '' + + rendered = TEMPLATE.read_text(encoding='utf-8') + replacements = { + 'ROUTER_ID_NAME': router_id_name, + 'PIHOLE_WEB_PORT_HEX': web_port_hex, + 'NOPREEMPT_LINE': nopreempt_line, + } + replacements.update({k: env[k] for k in required}) + for key, value in replacements.items(): + rendered = rendered.replace('${' + key + '}', value) + + OUTPUT.write_text(rendered.rstrip() + '\n', encoding='utf-8') + print(f'Rendered {OUTPUT}') + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/pihole/docker-compose.yaml b/pihole/docker-compose.yaml new file mode 100644 index 0000000..4677604 --- /dev/null +++ b/pihole/docker-compose.yaml @@ -0,0 +1,59 @@ +services: + unbound: + image: mvance/unbound:latest + container_name: unbound-pihole-primary + network_mode: host + restart: unless-stopped + environment: + TZ: ${TZ} + UNBOUND_PORT: "5335" + volumes: + - ${APPDATA_UNBOUND_ROOT}:/opt/unbound/etc/unbound/ + healthcheck: + test: ["CMD-SHELL", "hex=$$(printf '%04X' \"$$UNBOUND_PORT\"); grep -qi \":$$hex\" /proc/net/tcp /proc/net/tcp6 || exit 1"] + interval: 30s + timeout: 10s + retries: 6 + start_period: 20s + + pihole: + image: pihole/pihole:latest + container_name: pihole-primary + network_mode: host + restart: unless-stopped + depends_on: + unbound: + condition: service_healthy + environment: + TZ: ${TZ} + PIHOLE_WEB_PORT: ${PD_PIHOLE_WEB_PORT} + FTLCONF_webserver_api_password: ${PIHOLE_PASSWORD} + FTLCONF_dns_listeningMode: "LOCAL" + FTLCONF_dns_upstreams: "127.0.0.1#5335" + FTLCONF_webserver_port: ${PD_PIHOLE_WEB_PORT} + volumes: + - ${APPDATA_PIHOLE_ROOT}/etc-pihole:/etc/pihole + - ${APPDATA_PIHOLE_ROOT}/etc-dnsmasq.d:/etc/dnsmasq.d + cap_add: + - SYS_NICE + healthcheck: + test: ["CMD-SHELL", "hex=$$(printf '%04X' \"$$PIHOLE_WEB_PORT\"); grep -qi \":$$hex\" /proc/net/tcp /proc/net/tcp6 || exit 1"] + interval: 30s + timeout: 10s + retries: 6 + start_period: 40s + + keepalived: + image: osixia/keepalived:2.0.20 + container_name: keepalived-pihole-primary + network_mode: host + restart: unless-stopped + depends_on: + pihole: + condition: service_healthy + cap_add: + - NET_ADMIN + - NET_BROADCAST + - NET_RAW + volumes: + - ./keepalived.conf:/container/environment/01-custom/keepalived.conf:ro diff --git a/pihole/keepalived.conf.template b/pihole/keepalived.conf.template new file mode 100644 index 0000000..a92361f --- /dev/null +++ b/pihole/keepalived.conf.template @@ -0,0 +1,32 @@ +global_defs { + router_id ${ROUTER_ID_NAME} +} + +vrrp_script chk_pihole_web { + script "grep -qi ':${PIHOLE_WEB_PORT_HEX}' /proc/net/tcp /proc/net/tcp6" + interval 2 + fall 2 + rise 2 +} + +vrrp_instance VI_DNS { + state ${KEEPALIVED_STATE} + interface ${LAN_INTERFACE} + virtual_router_id ${KEEPALIVED_ROUTER_ID} + priority ${KEEPALIVED_PRIORITY} + advert_int 1 +${NOPREEMPT_LINE} + + authentication { + auth_type PASS + auth_pass ${KEEPALIVED_PASSWORD} + } + + virtual_ipaddress { + ${VIP_CIDR} + } + + track_script { + chk_pihole_web + } +} diff --git a/pihole/unbound.conf b/pihole/unbound.conf new file mode 100644 index 0000000..05df211 --- /dev/null +++ b/pihole/unbound.conf @@ -0,0 +1,27 @@ +server: + verbosity: 1 + interface: 127.0.0.1 + port: 5335 + do-ip4: yes + do-udp: yes + do-tcp: yes + do-ip6: no + + auto-trust-anchor-file: "/opt/unbound/etc/unbound/var/root.key" + val-permissive-mode: no + + num-threads: 2 + cache-min-ttl: 300 + cache-max-ttl: 86400 + prefetch: yes + prefetch-key: yes + minimal-responses: yes + + qname-minimisation: yes + hide-identity: yes + hide-version: yes + + access-control: 127.0.0.1/32 allow + access-control: 0.0.0.0/0 refuse + + root-hints: "/opt/unbound/etc/unbound/var/root.hints"