From bb96fc55f7106881dcc53ac4ad250faadd5b69f2 Mon Sep 17 00:00:00 2001 From: Frank Date: Mon, 24 Aug 2026 13:54:35 -0700 Subject: [PATCH] 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/ --- dankclaw/Caddyfile | 12 ++- dankclaw/dnsmasq/Containerfile | 6 -- dankclaw/dnsmasq/dnsmasq.conf | 24 ----- dankclaw/dnsmasq/dnsmasq.container | 33 ------- dankclaw/{dnsmasq => pihole}/addn-hosts | 6 +- dankclaw/pihole/env.example | 18 ++++ dankclaw/pihole/quadlet/pihole.container | 42 ++++++++ scripts/deploy-pihole-quadlet.sh | 118 +++++++++++++++++++++++ tests/{dnsmasq.bats => pihole.bats} | 70 +++++++++----- 9 files changed, 238 insertions(+), 91 deletions(-) delete mode 100644 dankclaw/dnsmasq/Containerfile delete mode 100644 dankclaw/dnsmasq/dnsmasq.conf delete mode 100644 dankclaw/dnsmasq/dnsmasq.container rename dankclaw/{dnsmasq => pihole}/addn-hosts (85%) create mode 100644 dankclaw/pihole/env.example create mode 100644 dankclaw/pihole/quadlet/pihole.container create mode 100755 scripts/deploy-pihole-quadlet.sh rename tests/{dnsmasq.bats => pihole.bats} (55%) diff --git a/dankclaw/Caddyfile b/dankclaw/Caddyfile index 4020be7..7f4b6e9 100644 --- a/dankclaw/Caddyfile +++ b/dankclaw/Caddyfile @@ -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 # ═══════════════════════════════════════════════════════════════ diff --git a/dankclaw/dnsmasq/Containerfile b/dankclaw/dnsmasq/Containerfile deleted file mode 100644 index dbf71e5..0000000 --- a/dankclaw/dnsmasq/Containerfile +++ /dev/null @@ -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"] diff --git a/dankclaw/dnsmasq/dnsmasq.conf b/dankclaw/dnsmasq/dnsmasq.conf deleted file mode 100644 index c95a4d3..0000000 --- a/dankclaw/dnsmasq/dnsmasq.conf +++ /dev/null @@ -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 diff --git a/dankclaw/dnsmasq/dnsmasq.container b/dankclaw/dnsmasq/dnsmasq.container deleted file mode 100644 index 857e05e..0000000 --- a/dankclaw/dnsmasq/dnsmasq.container +++ /dev/null @@ -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 diff --git a/dankclaw/dnsmasq/addn-hosts b/dankclaw/pihole/addn-hosts similarity index 85% rename from dankclaw/dnsmasq/addn-hosts rename to dankclaw/pihole/addn-hosts index a6832b7..384d9cc 100644 --- a/dankclaw/dnsmasq/addn-hosts +++ b/dankclaw/pihole/addn-hosts @@ -1,9 +1,10 @@ # A records for the flat franksdank.name scheme (dnsmasq addn-hosts format: -# [ ...]). +# [ ...]). 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 diff --git a/dankclaw/pihole/env.example b/dankclaw/pihole/env.example new file mode 100644 index 0000000..63a11e3 --- /dev/null +++ b/dankclaw/pihole/env.example @@ -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 diff --git a/dankclaw/pihole/quadlet/pihole.container b/dankclaw/pihole/quadlet/pihole.container new file mode 100644 index 0000000..dec833d --- /dev/null +++ b/dankclaw/pihole/quadlet/pihole.container @@ -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 diff --git a/scripts/deploy-pihole-quadlet.sh b/scripts/deploy-pihole-quadlet.sh new file mode 100755 index 0000000..d832784 --- /dev/null +++ b/scripts/deploy-pihole-quadlet.sh @@ -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 [--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 [--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 </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} ===" diff --git a/tests/dnsmasq.bats b/tests/pihole.bats similarity index 55% rename from tests/dnsmasq.bats rename to tests/pihole.bats index b700dab..5493db3 100644 --- a/tests/dnsmasq.bats +++ b/tests/pihole.bats @@ -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) }