Pi-hole swap: quadlet + env + records + Caddy vhost (retires dnsmasq)

- dankclaw/pihole/: pihole.container (quadlet, slirp4netns, 53+8083),
  env.example (ServerIP/TZ/DNS1/DNS2/WEBPASSWORD placeholders), addn-hosts
  (moved from dankclaw/dnsmasq/, + pihole.franksdank.cloud record)
- Caddyfile: pihole.franksdank.cloud vhost (family basicauth -> 8083)
- scripts/deploy-pihole-quadlet.sh: idempotent quadlet installer
- tests/pihole.bats replaces tests/dnsmasq.bats (same record validation
  + cross-checks, new quadlet/env seams); retired files in .AGENTTRASH/
This commit is contained in:
Frank
2026-08-24 13:54:35 -07:00
parent cf3da02af7
commit bb96fc55f7
9 changed files with 238 additions and 91 deletions
+10 -2
View File
@@ -7,8 +7,8 @@
# - dankserver services (192.168.0.235): by published LAN port
#
# Naming: flat franksdank.cloud scheme (see docs/Infrastructure/LAN-DNS.md).
# DNS for these names is served by the dnsmasq quadlet on this host
# (dankclaw/dnsmasq/addn-hosts — every name resolves to 192.168.0.143)
# DNS for these names is served by the Pi-hole quadlet on this host
# (dankclaw/pihole/addn-hosts — every name resolves to 192.168.0.143)
# and handed to LAN clients by the SURFboard G36 router.
#
# The basicauth hash below is a placeholder. deploy.sh renders the real
@@ -147,6 +147,14 @@ loki.franksdank.cloud {
reverse_proxy loki:3100
}
# --- Pi-hole (quadlet on slirp4netns; published 8083) ---
pihole.franksdank.cloud {
basicauth {
dankuser REPLACE_WITH_HASH
}
reverse_proxy 192.168.0.143:8083
}
# ═══════════════════════════════════════════════════════════════
# Services on dankserver (192.168.0.235) — published LAN ports
# ═══════════════════════════════════════════════════════════════
-6
View File
@@ -1,6 +0,0 @@
# Minimal dnsmasq image for the LAN DNS quadlet (dankclaw).
# Build on dankclaw: podman build -t localhost/dnsmasq:latest -f Containerfile .
FROM alpine:3.20
RUN apk add --no-cache dnsmasq
# -k keeps dnsmasq in the foreground (PID 1) so the container stays up.
CMD ["/usr/sbin/dnsmasq", "-k", "-C", "/etc/dnsmasq.conf"]
-24
View File
@@ -1,24 +0,0 @@
# dnsmasq — LAN DNS for the homelab.
#
# Runs in a rootless podman quadlet on dankclaw (192.168.0.143) with port 53
# published to the LAN. Serves the flat franksdank.cloud / franksdank.casa
# name scheme from addn-hosts (one A record per Caddy vhost); everything else
# goes to the pinned upstream resolvers. The SURFboard G36 router hands this
# server out to DHCP clients, so LAN devices resolve service names without
# per-device config. See docs/Infrastructure/LAN-DNS.md.
# The slirp netns has no useful /etc/resolv.conf; pin upstreams instead.
no-resolv
server=1.1.1.1
server=1.0.0.1
# Local service names (must stay in sync with the Caddyfile vhosts).
addn-hosts=/etc/dnsmasq/addn-hosts
# Never resolve single-label names or private/bogus ranges as public names.
domain-needed
bogus-priv
# Cache.
cache-size=1000
neg-ttl=300
-33
View File
@@ -1,33 +0,0 @@
# Quadlet unit: dnsmasq LAN DNS (rootless, boot-persistent).
#
# Install path on dankclaw: ~/.config/containers/systemd/dnsmasq.container
# Unit name: dnsmasq.service (container name: dnsmasq)
#
# The LAN's authoritative DNS for the flat franksdank.cloud / franksdank.casa
# name scheme. It serves A records from addn-hosts (one per Caddy vhost) and
# forwards everything else to pinned upstream resolvers. The SURFboard G36
# router hands this server out to DHCP clients (DNS Relay -> statically
# configured -> 192.168.0.143), so no client needs per-device config.
#
# Runs on slirp4netns with port 53 published to the LAN (the same pattern as
# open-webui). Config mounts from /home/dank/Containers/dnsmasq. See
# docs/Infrastructure/LAN-DNS.md.
[Unit]
Description=dnsmasq LAN DNS container
After=network.target
[Container]
ContainerName=dnsmasq
Network=slirp4netns
Image=localhost/dnsmasq:latest
PublishPort=53:53/tcp
PublishPort=53:53/udp
Volume=/home/dank/Containers/dnsmasq/dnsmasq.conf:/etc/dnsmasq.conf:ro
Volume=/home/dank/Containers/dnsmasq/addn-hosts:/etc/dnsmasq/addn-hosts:ro
[Service]
Restart=always
[Install]
WantedBy=default.target
@@ -1,9 +1,10 @@
# A records for the flat franksdank.name scheme (dnsmasq addn-hosts format:
# <IP> <name> [<name> ...]).
# <IP> <name> [<name> ...]). Served by the Pi-hole quadlet (FTL is a
# dnsmasq fork): the quadlet mounts this file into /etc/dnsmasq.d/ read-only.
#
# Caddy is consolidated on dankclaw (192.168.0.143): it fronts every service
# on both hosts, so every name resolves to that one IP. Keep in sync with the
# Caddyfile (tests/dnsmasq.bats cross-checks this).
# Caddyfile (tests/pihole.bats cross-checks this).
#
# 192.168.0.143 = dankclaw (runs the Caddy container + the AI/dev stack)
# 192.168.0.235 = dankserver (media stack; reached by Caddy via published
@@ -36,3 +37,4 @@
192.168.0.143 grafana.franksdank.cloud
192.168.0.143 prometheus.franksdank.cloud
192.168.0.143 loki.franksdank.cloud
192.168.0.143 pihole.franksdank.cloud
+18
View File
@@ -0,0 +1,18 @@
# Pi-hole container environment (dankclaw).
# Copy to /home/dank/Containers/pihole/env and fill real values.
# The real env file is never committed (it holds the web UI password).
# The host IP the container answers for (also shown in the web UI).
ServerIP=192.168.0.143
# Timezone for the web UI and logs.
TZ=America/Los_Angeles
# Pinned upstream resolvers. The slirp netns has no useful resolv.conf, so
# pin them (mirrors the retired dnsmasq.conf server= lines).
DNS1=1.1.1.1
DNS2=1.0.0.1
# Web UI password (the shared family password; the same credential as
# CADDY_BASICAUTH_HASH in the host .env).
WEBPASSWORD=changeme
+42
View File
@@ -0,0 +1,42 @@
# Quadlet unit: Pi-hole LAN DNS (rootless, boot-persistent).
#
# Install path on dankclaw: ~/.config/containers/systemd/pihole.container
# Unit name: pihole.service (container name: pihole)
#
# The LAN's authoritative DNS for the flat franksdank.cloud / franksdank.casa
# name scheme. It retired the dnsmasq quadlet (2026-08-24): Pi-hole FTL is a
# dnsmasq fork, so the addn-hosts record file carries over unchanged and is
# mounted into /etc/dnsmasq.d/ (the FTL dnsmasq config dir). It serves one A
# record per Caddy vhost and forwards everything else to the pinned upstream
# resolvers (DNS1/DNS2 in the env file). The SURFboard G36 router hands this
# server out to DHCP clients (DNS statically configured -> 192.168.0.143),
# so no client needs per-device config.
#
# Runs on slirp4netns with port 53 (DNS) and 8083 (web UI) published to the
# LAN (the same pattern as open-webui). Config + state mount from
# /home/dank/Containers/pihole. The web UI password is WEBPASSWORD in the env
# file. See docs/Infrastructure/LAN-DNS.md.
[Unit]
Description=Pi-hole LAN DNS container
After=network-online.target
Wants=network-online.target
[Container]
ContainerName=pihole
Image=docker.io/pihole/pihole:latest
Network=slirp4netns
PublishPort=0.0.0.0:53:53/udp
PublishPort=0.0.0.0:53:53/tcp
PublishPort=0.0.0.0:8083:80/tcp
EnvironmentFile=/home/dank/Containers/pihole/env
Volume=/home/dank/Containers/pihole/etc-pihole:/etc/pihole
Volume=/home/dank/Containers/pihole/etc-dockers:/etc/dockers
Volume=/home/dank/Containers/pihole/addn-hosts:/etc/dnsmasq.d/addn-hosts:ro
[Service]
Restart=always
RestartSec=5
[Install]
WantedBy=default.target
+118
View File
@@ -0,0 +1,118 @@
#!/usr/bin/env bash
# deploy-pihole-quadlet.sh — Install and (re)start the Pi-hole LAN DNS on
# dankclaw. Pi-hole retired the dnsmasq quadlet (2026-08-24).
#
# Pi-hole is boot-persistent via Quadlet (see AGENTS.md). This script:
# 1. Pushes main to origin
# 2. Pulls the repo on the target
# 3. Creates the data dirs and copies the quadlet unit + env + addn-hosts
# 4. daemon-reloads and starts pihole (first run pulls the image)
#
# It is idempotent: it stops any running unit and removes any container with
# the name (a prior run or a bare-container leftover) before reinstalling and
# starting. The data dirs (etc-pihole, etc-dockers) are preserved across
# reruns, so the blocklist and web settings survive. The env file is created
# from env.example only if absent; fill the real WEBPASSWORD before the web
# UI is usable.
#
# Usage:
# ./scripts/deploy-pihole-quadlet.sh <target> [--dry-run]
# ./scripts/deploy-pihole-quadlet.sh dankclaw
set -euo pipefail
REMOTE_USER="dank"
REMOTE_HOST="dankclaw"
REMOTE_DIR="/home/dank/serverAppConfigs"
QDIR="dankclaw/pihole"
UNIT="pihole.service"
DATA_DIR="/home/dank/Containers/pihole"
TARGET="${1:-}"
DRY_RUN=0
if [ "${2:-}" = "--dry-run" ]; then
DRY_RUN=1
fi
if [ -z "${TARGET:-}" ]; then
echo "Usage: $0 <target> [--dry-run]"
exit 1
fi
if [ "${TARGET}" != "dankclaw" ]; then
echo "This stack runs only on dankclaw (got: ${TARGET})"
exit 1
fi
# ---------------------------------------------------------------------------
# Local pre-flight
# ---------------------------------------------------------------------------
cd "$(dirname "$0")/.."
if ! git diff --quiet HEAD; then
echo "FAIL: Local working tree has uncommitted changes. Commit first."
exit 1
fi
if [ "$DRY_RUN" = "1" ]; then
echo "[dry-run] Would push main, pull on ${REMOTE_HOST}, install ${UNIT}, and start it (data at ${DATA_DIR})."
exit 0
fi
echo "=== Deploying pihole quadlet to ${REMOTE_HOST} ==="
echo "[1/3] Pushing local main to origin..."
git push origin main
REMOTE="${REMOTE_USER}@${REMOTE_HOST}"
echo "[2/3] Installing and starting ${UNIT} on ${REMOTE_HOST}..."
ssh "$REMOTE" bash -s <<REMOTE_EOF
set -euo pipefail
cd '${REMOTE_DIR}'
echo ' Pulling repo...'
git pull origin main
echo ' Stopping any running unit (idempotent)...'
systemctl --user stop '${UNIT}' 2>/dev/null || true
echo ' Removing any container with the name (prior run or bare-container leftover)...'
podman rm -f pihole 2>/dev/null || true
echo ' Creating the data dirs (preserved across reruns)...'
mkdir -p '${DATA_DIR}/etc-pihole' '${DATA_DIR}/etc-dockers'
echo ' Creating the env file from env.example if absent...'
if [ ! -f '${DATA_DIR}/env' ]; then
cp ${QDIR}/env.example '${DATA_DIR}/env'
echo ' env created: set the real WEBPASSWORD before the web UI is usable'
else
echo ' env present, keeping it'
fi
echo ' Copying the record file (addn-hosts)...'
cp ${QDIR}/addn-hosts '${DATA_DIR}/addn-hosts'
echo ' Copying the quadlet unit into ~/.config/containers/systemd/ ...'
mkdir -p ~/.config/containers/systemd
cp ${QDIR}/quadlet/pihole.container ~/.config/containers/systemd/
echo ' daemon-reload (generates the unit)...'
# Quadlet units are generator-managed: systemctl enable fails on them. The
# [Install] WantedBy=default.target in the .container file is itself the
# enablement (is-enabled reports "generated"); with user linger on it starts
# at boot. So: daemon-reload, then start.
systemctl --user daemon-reload
echo ' Starting (first run pulls the image; this can take a while)...'
systemctl --user start '${UNIT}'
echo ' Unit state:'
systemctl --user --no-pager status '${UNIT}' 2>&1 | grep -E "●|Active:" || true
REMOTE_EOF
echo "[3/3] Verifying pihole is up..."
ssh "$REMOTE" '
echo " is-enabled: $(systemctl --user is-enabled pihole.service 2>&1) (boot-persistent)"
echo " port 53:"; (ss -ulpn 2>/dev/null) | grep -E ":53\b" || echo " (not listening yet)"
echo " resolution:"; dig @127.0.0.1 romm.franksdank.cloud +short 2>/dev/null || echo " (no answer yet)"
' || true
echo "=== pihole quadlet deployed to ${REMOTE_HOST} ==="
+46 -24
View File
@@ -1,43 +1,60 @@
#!/usr/bin/env bats
# Seam: LAN DNS (dnsmasq) on dankclaw.
# Seam: LAN DNS (Pi-hole) on dankclaw.
#
# The dnsmasq quadlet is the LAN's authoritative DNS for the flat
# franksdank.cloud / franksdank.casa name scheme. Every Caddy vhost name
# must have an A record pointing at the host that runs it, records must
# stay inside the LAN, and the quadlet must be boot-persistent.
# The Pi-hole quadlet is the LAN's authoritative DNS for the flat
# franksdank.cloud / franksdank.casa name scheme. It retired the dnsmasq
# quadlet (2026-08-24; Pi-hole FTL is a dnsmasq fork, so the addn-hosts
# record file carries over unchanged). Every Caddy vhost name must have an
# A record pointing at the host that runs it, records must stay inside the
# LAN, and the quadlet must be boot-persistent.
load helpers
DNSMASQ_DIR="$REPO_ROOT/dankclaw/dnsmasq"
QUADLET="$DNSMASQ_DIR/dnsmasq.container"
CONF="$DNSMASQ_DIR/dnsmasq.conf"
HOSTS="$DNSMASQ_DIR/addn-hosts"
PIHOLE_DIR="$REPO_ROOT/dankclaw/pihole"
QUADLET="$PIHOLE_DIR/quadlet/pihole.container"
ENV_EXAMPLE="$PIHOLE_DIR/env.example"
HOSTS="$PIHOLE_DIR/addn-hosts"
@test "the dnsmasq quadlet container file exists" {
@test "the pihole quadlet container file exists" {
[ -f "$QUADLET" ]
}
@test "the dnsmasq quadlet is installed for boot" {
@test "the pihole quadlet is installed for boot" {
grep -q 'WantedBy=default.target' "$QUADLET"
}
@test "the dnsmasq quadlet publishes port 53 tcp and udp" {
grep -qE '^PublishPort=53:53/tcp$' "$QUADLET"
grep -qE '^PublishPort=53:53/udp$' "$QUADLET"
@test "the pihole quadlet publishes port 53 tcp and udp" {
grep -qE '^PublishPort=0\.0\.0\.0:53:53/tcp$' "$QUADLET"
grep -qE '^PublishPort=0\.0\.0\.0:53:53/udp$' "$QUADLET"
}
@test "the dnsmasq quadlet restarts on failure" {
@test "the pihole quadlet publishes the web UI on 8083" {
grep -qE '^PublishPort=0\.0\.0\.0:8083:80/tcp$' "$QUADLET"
}
@test "the pihole quadlet restarts on failure" {
grep -q '^Restart=always' "$QUADLET"
}
@test "dnsmasq.conf exists and pins upstream resolvers" {
[ -f "$CONF" ]
grep -q '^no-resolv' "$CONF"
grep -qE '^server=[0-9.]+$' "$CONF"
@test "the pihole quadlet reads its env file" {
grep -q '^EnvironmentFile=/home/dank/Containers/pihole/env$' "$QUADLET"
}
@test "dnsmasq.conf loads the addn-hosts record file" {
grep -q '^addn-hosts=' "$CONF"
@test "the pihole quadlet mounts the addn-hosts record file read-only" {
grep -qE '^Volume=/home/dank/Containers/pihole/addn-hosts:/etc/dnsmasq\.d/addn-hosts:ro$' "$QUADLET"
}
@test "the pihole env example has placeholder values only" {
[ -f "$ENV_EXAMPLE" ]
grep -q '^WEBPASSWORD=changeme$' "$ENV_EXAMPLE"
grep -q '^ServerIP=192\.168\.0\.143$' "$ENV_EXAMPLE"
grep -q '^DNS1=1\.1\.1\.1$' "$ENV_EXAMPLE"
}
@test "the retired dnsmasq quadlet files are out of the repo" {
[ ! -f "$REPO_ROOT/dankclaw/dnsmasq/dnsmasq.container" ]
[ ! -f "$REPO_ROOT/dankclaw/dnsmasq/dnsmasq.conf" ]
[ ! -f "$REPO_ROOT/dankclaw/dnsmasq/Containerfile" ]
}
@test "every addn-hosts line is a valid IPv4 address plus hostnames" {
@@ -98,6 +115,10 @@ record_ip_for() {
done <<< "$names"
}
@test "the pihole admin name has an A record on 192.168.0.143" {
[ "$(record_ip_for pihole.franksdank.cloud)" = "192.168.0.143" ]
}
@test "no addn-hosts records point at the old per-host split (192.168.0.235)" {
! awk '!/^[[:space:]]*#/ && NF {print $1}' "$HOSTS" | grep -q '^192\.168\.0\.235$'
}
@@ -106,9 +127,10 @@ record_ip_for() {
! grep -qE '\billms\.franksdank\.cloud\b' "$HOSTS"
}
@test "no secret material in the dnsmasq files" {
@test "no secret material in the pihole files" {
local f
for f in "$DNSMASQ_DIR"/*; do
while IFS= read -r f; do
has_no_leaked_secret "$f"
done
has_no_leaked_sudo_pw "$f"
done < <(find "$PIHOLE_DIR" -type f | sort)
}