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:
+10
-2
@@ -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
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Executable
+118
@@ -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} ==="
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user