Scaffold PD Pi-hole primary stack

This commit is contained in:
Fizzlepoof
2026-05-15 03:17:56 +00:00
parent 4d78dd4524
commit 5cc5b5f569
10 changed files with 322 additions and 8 deletions

3
.gitignore vendored
View File

@@ -16,6 +16,9 @@ __pycache__/
# Temp scripts # Temp scripts
update-homelab-docs.py update-homelab-docs.py
# Generated stack config
pihole/keepalived.conf
# Editor # Editor
.vscode/ .vscode/
.idea/ .idea/

View File

@@ -1,6 +1,6 @@
# Pi-hole Deployment Plan # 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 **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) ## 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` **Web UI port:** `8953`
### Pre-Deploy ### Pre-Deploy
@@ -210,14 +211,16 @@ LAN_INTERFACE=eno1
### Deploy ### Deploy
Preferred PD-first flow now that the repo-side stack exists:
```bash ```bash
cd /mnt/docker-ssd/docker/compose/pihole cd /mnt/docker-ssd/docker/compose/pihole
# Copy unbound.conf to appdata cp .env.example .env
sudo cp unbound.conf /mnt/tank/docker/appdata/unbound-primary/ nano .env
sudo docker compose --env-file .env config /usr/bin/bash bin/prepare_pd.sh
sudo docker compose --env-file .env up -d /usr/bin/bash bin/prepare_pd.sh --up
sudo docker logs pihole --tail 20 sudo docker logs pihole-primary --tail 20
sudo docker logs unbound --tail 20 sudo docker logs unbound-pihole-primary --tail 20
``` ```
--- ---

View File

@@ -6,6 +6,7 @@
## Infrastructure ## Infrastructure
- [ ] Deploy Pi-hole 3-node HA cluster (PD + Serenity + RPi4) — see docs/planning/DEPLOY_PIHOLE.md - [ ] 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 - 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 - [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) - [ ] Set up off-site backup for vital data (appdata, databases, config repo, photos)

18
pihole/.env.example Normal file
View File

@@ -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

47
pihole/README.md Normal file
View File

@@ -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.

57
pihole/bin/prepare_pd.sh Executable file
View File

@@ -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

View File

@@ -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())

View File

@@ -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

View File

@@ -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
}
}

27
pihole/unbound.conf Normal file
View File

@@ -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"