Scaffold PD Pi-hole primary stack
This commit is contained in:
18
pihole/.env.example
Normal file
18
pihole/.env.example
Normal 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
47
pihole/README.md
Normal 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
57
pihole/bin/prepare_pd.sh
Executable 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
|
||||
67
pihole/bin/render_keepalived_conf.py
Executable file
67
pihole/bin/render_keepalived_conf.py
Executable 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())
|
||||
59
pihole/docker-compose.yaml
Normal file
59
pihole/docker-compose.yaml
Normal 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
|
||||
32
pihole/keepalived.conf.template
Normal file
32
pihole/keepalived.conf.template
Normal 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
27
pihole/unbound.conf
Normal 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"
|
||||
Reference in New Issue
Block a user