From 55d062ea7b055bba9fe625703d5b7db853c95c5e Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 21 Aug 2026 16:31:42 -0700 Subject: [PATCH] DNS names: flat franksdank.cloud scheme + dnsmasq LAN DNS on dankclaw Phase 1 of the local-DNS-name work. Users type a name, not a port. - dnsmasq quadlet on dankclaw (rootless, slirp4netns, port 53 published): serves the flat name scheme from addn-hosts, upstream 1.1.1.1/1.0.0.1. Image built from the in-repo Containerfile (localhost/dnsmasq:latest). - Caddyfiles (both hosts): all *.dankclaw.local / *.dankserver.local vhosts renamed to flat .franksdank.cloud. Explicit http:// site addresses for now; TLS (wildcard LE via DNS-01/Porkbun) lands in phase 2. - Drift reconciliation (live /etc/caddy/Caddyfile on dankserver differs from the repo): added the live-only llms.franksdank.cloud vhost (OpenWebUI on :3000). Dropped the legacy search vhost from dankserver (the name belongs to dankclaw's gluetun-routed SearXNG; the legacy instance stays reachable at 192.168.0.235:8081). - Homepage service links updated to the new names. - New test seams (TDD): tests/dnsmasq.bats (quadlet boot-persistence, port 53, conf/record validity, Caddyfile<->addn-hosts cross-check on the right host IPs); caddyfile.bats naming-convention tests updated. LAN clients get this DNS from the SURFboard G36 (DNS Relay -> statically configured -> 192.168.0.143); the router change is a pending user step. --- dankclaw/Caddyfile | 28 ++++-- dankclaw/dnsmasq/Containerfile | 4 + dankclaw/dnsmasq/addn-hosts | 34 +++++++ dankclaw/dnsmasq/dnsmasq.conf | 24 +++++ dankclaw/dnsmasq/dnsmasq.container | 32 +++++++ dankserver/Caddyfile | 51 +++++----- shared/homepage/dankserver.services.json | 8 +- shared/homepage/services.json | 16 ++-- tests/caddyfile.bats | 11 ++- tests/dnsmasq.bats | 114 +++++++++++++++++++++++ 10 files changed, 274 insertions(+), 48 deletions(-) create mode 100644 dankclaw/dnsmasq/Containerfile create mode 100644 dankclaw/dnsmasq/addn-hosts create mode 100644 dankclaw/dnsmasq/dnsmasq.conf create mode 100644 dankclaw/dnsmasq/dnsmasq.container create mode 100644 tests/dnsmasq.bats diff --git a/dankclaw/Caddyfile b/dankclaw/Caddyfile index d229daf..14b619e 100644 --- a/dankclaw/Caddyfile +++ b/dankclaw/Caddyfile @@ -2,14 +2,22 @@ # Reverse proxy for the Dankclaw stack. Caddy runs as a container on the # podman network; upstreams are container names. Homepage is password # protected. Replace the bcrypt hashes below (caddy hash-password) at deploy. +# +# Naming: flat franksdank.cloud scheme (see docs/Infrastructure/Domain-DNS.md). +# DNS for these names is served by the dnsmasq quadlet on this host +# (dankclaw/dnsmasq/addn-hosts) and handed to LAN clients by the SURFboard +# G36 router (DNS Relay -> statically configured -> 192.168.0.143). +# +# Phase 1 uses explicit http:// site addresses (no TLS yet). Phase 2 drops +# the prefix and adds a wildcard Let's Encrypt cert via DNS-01 (Porkbun). { admin off - email admin@dankclaw.local + email admin@franksdank.cloud } # --- Homepage (password protected) --- -dankclaw.local { +http://dankclaw.franksdank.cloud { basicauth { dank $2a$14$REPLACE_WITH_HASH } @@ -18,7 +26,7 @@ dankclaw.local { } # --- SearXNG (behind the Gluetun tunnel) --- -search.dankclaw.local { +http://search.franksdank.cloud { basicauth { dank $2a$14$REPLACE_WITH_HASH } @@ -26,14 +34,14 @@ search.dankclaw.local { } # --- ComfyUI --- -comfyui.dankclaw.local { +http://comfyui.franksdank.cloud { reverse_proxy comfyui:8188 } # --- OpenWebUI --- # open-webui is a quadlet container on slirp4netns (isolated), so Caddy cannot # reach it by container name. Proxy to its published LAN port instead. -chat.dankclaw.local { +http://chat.franksdank.cloud { basicauth { dank $2a$14$REPLACE_WITH_HASH } @@ -41,7 +49,7 @@ chat.dankclaw.local { } # --- Hermes --- -hermes.dankclaw.local { +http://hermes.franksdank.cloud { basicauth { dank $2a$14$REPLACE_WITH_HASH } @@ -49,12 +57,12 @@ hermes.dankclaw.local { } # --- Gitea (migrated) --- -gitea.dankclaw.local { +http://gitea.franksdank.cloud { reverse_proxy gitea-server:3000 } # --- Grafana --- -grafana.dankclaw.local { +http://grafana.franksdank.cloud { basicauth { dank $2a$14$REPLACE_WITH_HASH } @@ -62,7 +70,7 @@ grafana.dankclaw.local { } # --- Prometheus --- -prometheus.dankclaw.local { +http://prometheus.franksdank.cloud { basicauth { dank $2a$14$REPLACE_WITH_HASH } @@ -70,7 +78,7 @@ prometheus.dankclaw.local { } # --- Loki --- -loki.dankclaw.local { +http://loki.franksdank.cloud { basicauth { dank $2a$14$REPLACE_WITH_HASH } diff --git a/dankclaw/dnsmasq/Containerfile b/dankclaw/dnsmasq/Containerfile new file mode 100644 index 0000000..d73ea1b --- /dev/null +++ b/dankclaw/dnsmasq/Containerfile @@ -0,0 +1,4 @@ +# 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 diff --git a/dankclaw/dnsmasq/addn-hosts b/dankclaw/dnsmasq/addn-hosts new file mode 100644 index 0000000..cec6d0c --- /dev/null +++ b/dankclaw/dnsmasq/addn-hosts @@ -0,0 +1,34 @@ +# A records for the flat franksdank.name scheme (dnsmasq addn-hosts format: +# [ ...]). Every Caddy vhost name has exactly one record, +# pointing at the host that runs it. Keep in sync with the Caddyfiles +# (tests/dnsmasq.bats cross-checks this). +# +# 192.168.0.235 = dankserver (media/library stack) +# 192.168.0.143 = dankclaw (AI/search/dev stack) + +# --- dankserver services --- +192.168.0.235 dankserver.franksdank.cloud +192.168.0.235 plex.franksdank.cloud +192.168.0.235 plex.franksdank.casa +192.168.0.235 books.franksdank.cloud +192.168.0.235 llms.franksdank.cloud +192.168.0.235 romm.franksdank.cloud +192.168.0.235 files.franksdank.cloud +192.168.0.235 jellyfin.franksdank.cloud +192.168.0.235 radarr.franksdank.cloud +192.168.0.235 sonarr.franksdank.cloud +192.168.0.235 lidarr.franksdank.cloud +192.168.0.235 readarr.franksdank.cloud +192.168.0.235 prowlarr.franksdank.cloud +192.168.0.235 qbittorrent.franksdank.cloud + +# --- dankclaw services --- +192.168.0.143 dankclaw.franksdank.cloud +192.168.0.143 search.franksdank.cloud +192.168.0.143 comfyui.franksdank.cloud +192.168.0.143 chat.franksdank.cloud +192.168.0.143 hermes.franksdank.cloud +192.168.0.143 gitea.franksdank.cloud +192.168.0.143 grafana.franksdank.cloud +192.168.0.143 prometheus.franksdank.cloud +192.168.0.143 loki.franksdank.cloud diff --git a/dankclaw/dnsmasq/dnsmasq.conf b/dankclaw/dnsmasq/dnsmasq.conf new file mode 100644 index 0000000..c95a4d3 --- /dev/null +++ b/dankclaw/dnsmasq/dnsmasq.conf @@ -0,0 +1,24 @@ +# 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 new file mode 100644 index 0000000..7d8b751 --- /dev/null +++ b/dankclaw/dnsmasq/dnsmasq.container @@ -0,0 +1,32 @@ +# 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 /opt/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=/opt/dnsmasq/dnsmasq.conf:/etc/dnsmasq.conf:ro +Volume=/opt/dnsmasq/addn-hosts:/etc/dnsmasq/addn-hosts:ro + +[Service] +Restart=always + +[Install] +WantedBy=default.target diff --git a/dankserver/Caddyfile b/dankserver/Caddyfile index 6248a43..a42123f 100644 --- a/dankserver/Caddyfile +++ b/dankserver/Caddyfile @@ -1,14 +1,22 @@ # Dankserver Caddyfile -# Deploy: copy to /etc/caddy/Caddyfile on dankserver +# Deploy: copy to /etc/caddy/Caddyfile on dankserver (host Caddy service) # See ../docs/Dankserver/ for service documentation. +# +# Naming: flat franksdank.cloud scheme (see docs/Infrastructure/Domain-DNS.md). +# DNS for these names is served by the dnsmasq quadlet on dankclaw +# (dankclaw/dnsmasq/addn-hosts) and handed to LAN clients by the SURFboard +# G36 router (DNS Relay -> statically configured -> 192.168.0.143). +# +# Phase 1 uses explicit http:// site addresses (no TLS yet). Phase 2 drops +# the prefix and adds a wildcard Let's Encrypt cert via DNS-01 (Porkbun). { admin off - email admin@dankserver.local + email admin@franksdank.cloud } # --- Homepage (password protected) --- -dankserver.local { +http://dankserver.franksdank.cloud { basicauth { dank $2a$14$REPLACE_WITH_HASH } @@ -17,29 +25,26 @@ dankserver.local { } # --- Plex (existing, kept) --- -plex.franksdank.casa { +http://plex.franksdank.casa { reverse_proxy localhost:32400 } -plex.franksdank.cloud { - reverse_proxy localhost:32400 -} - -plex.dankserver.local { +http://plex.franksdank.cloud { reverse_proxy localhost:32400 } # --- Calibre-Web (existing, kept) --- -books.franksdank.cloud { +http://books.franksdank.cloud { reverse_proxy localhost:8083 } -books.dankserver.local { - reverse_proxy localhost:8083 +# --- OpenWebUI (live name from the host Caddyfile; LLM chat UI on this host) --- +http://llms.franksdank.cloud { + reverse_proxy localhost:3000 } # --- RomM --- -romm.dankserver.local { +http://romm.franksdank.cloud { basicauth { dank $2a$14$REPLACE_WITH_HASH } @@ -47,7 +52,7 @@ romm.dankserver.local { } # --- Copyparty --- -files.dankserver.local { +http://files.franksdank.cloud { basicauth { dank $2a$14$REPLACE_WITH_HASH } @@ -55,7 +60,7 @@ files.dankserver.local { } # --- Jellyfin --- -jellyfin.dankserver.local { +http://jellyfin.franksdank.cloud { reverse_proxy 127.0.0.1:8096 transport http { @@ -64,36 +69,36 @@ jellyfin.dankserver.local { } # --- *arr Stack --- -radarr.dankserver.local { +http://radarr.franksdank.cloud { reverse_proxy localhost:7878 } -sonarr.dankserver.local { +http://sonarr.franksdank.cloud { reverse_proxy localhost:8989 } -lidarr.dankserver.local { +http://lidarr.franksdank.cloud { reverse_proxy localhost:8686 } -readarr.dankserver.local { +http://readarr.franksdank.cloud { reverse_proxy localhost:8787 } -prowlarr.dankserver.local { +http://prowlarr.franksdank.cloud { reverse_proxy localhost:9696 } -qbittorrent.dankserver.local { +http://qbittorrent.franksdank.cloud { reverse_proxy localhost:8080 } # --- Optional: Jackett --- -# jackett.dankserver.local { +# http://jackett.franksdank.cloud { # reverse_proxy localhost:9117 # } # --- Optional: Whisparr --- -# whisparr.dankserver.local { +# http://whisparr.franksdank.cloud { # reverse_proxy localhost:6969 # } diff --git a/shared/homepage/dankserver.services.json b/shared/homepage/dankserver.services.json index c5a372d..104e4ee 100644 --- a/shared/homepage/dankserver.services.json +++ b/shared/homepage/dankserver.services.json @@ -9,25 +9,25 @@ }, { "name": "RomM", - "url": "http://romm.dankserver.local", + "url": "http://romm.franksdank.cloud", "description": "Retro game manager and player for the ROM collection.", "docs": "docs/Dankserver/RomM.md" }, { "name": "Copyparty", - "url": "http://files.dankserver.local", + "url": "http://files.franksdank.cloud", "description": "High-speed file sharing and streaming server.", "docs": "docs/Dankserver/Copyparty.md" }, { "name": "Calibre-Web", - "url": "http://books.dankserver.local", + "url": "http://books.franksdank.cloud", "description": "E-book library browser and reader.", "docs": "docs/Dankserver/Calibre-Web.md" }, { "name": "Plex", - "url": "http://plex.dankserver.local", + "url": "http://plex.franksdank.cloud", "description": "Media server (existing host service, proxied).", "docs": "docs/Dankserver/Dankserver Stack.md" } diff --git a/shared/homepage/services.json b/shared/homepage/services.json index a8d733a..8657806 100644 --- a/shared/homepage/services.json +++ b/shared/homepage/services.json @@ -9,49 +9,49 @@ }, { "name": "SearXNG", - "url": "http://search.dankclaw.local", + "url": "http://search.franksdank.cloud", "description": "Privacy-respecting metasearch engine (through Gluetun VPN).", "docs": "docs/Dankclaw/SearXNG.md" }, { "name": "ComfyUI", - "url": "http://comfyui.dankclaw.local", + "url": "http://comfyui.franksdank.cloud", "description": "Node-based AI image generation (CPU backend on Strix Halo).", "docs": "docs/Dankclaw/ComfyUI.md" }, { "name": "OpenWebUI", - "url": "http://chat.dankclaw.local", + "url": "http://chat.franksdank.cloud", "description": "Chat interface for local LLMs (llama.cpp on :7770).", "docs": "docs/Dankclaw/OpenWebUI.md" }, { "name": "Hermes", - "url": "http://hermes.dankclaw.local", + "url": "http://hermes.franksdank.cloud", "description": "Self-improving AI agent (Nous Research).", "docs": "docs/Dankclaw/Hermes.md" }, { "name": "Gitea", - "url": "http://gitea.dankclaw.local", + "url": "http://gitea.franksdank.cloud", "description": "Self-hosted Git service with issues and PRs.", "docs": "docs/Dankclaw/Gitea.md" }, { "name": "Grafana", - "url": "http://grafana.dankclaw.local", + "url": "http://grafana.franksdank.cloud", "description": "Dashboards for metrics and logs.", "docs": "docs/Infrastructure/Grafana.md" }, { "name": "Prometheus", - "url": "http://prometheus.dankclaw.local", + "url": "http://prometheus.franksdank.cloud", "description": "Metrics collection for the whole stack.", "docs": "docs/Infrastructure/Prometheus.md" }, { "name": "Loki", - "url": "http://loki.dankclaw.local", + "url": "http://loki.franksdank.cloud", "description": "Log storage and query backend.", "docs": "docs/Infrastructure/Loki.md" } diff --git a/tests/caddyfile.bats b/tests/caddyfile.bats index 0a73d50..714d516 100644 --- a/tests/caddyfile.bats +++ b/tests/caddyfile.bats @@ -12,9 +12,14 @@ load helpers [ -f "$REPO_ROOT/dankclaw/Caddyfile" ] } -@test "Caddyfiles use the .local hostname convention" { - grep -qE '\.dankserver\.local' "$REPO_ROOT/dankserver/Caddyfile" - grep -qE '\.dankclaw\.local' "$REPO_ROOT/dankclaw/Caddyfile" +@test "Caddyfiles use the flat franksdank.cloud hostname convention" { + grep -qE '\.franksdank\.cloud' "$REPO_ROOT/dankserver/Caddyfile" + grep -qE '\.franksdank\.cloud' "$REPO_ROOT/dankclaw/Caddyfile" +} + +@test "no .local vhost names remain in either Caddyfile" { + ! grep -qE '^([a-z0-9.-]+\.)?(dankserver|dankclaw)\.local[[:space:]]*\{' \ + "$REPO_ROOT/dankserver/Caddyfile" "$REPO_ROOT/dankclaw/Caddyfile" } @test "Caddyfiles have balanced braces" { diff --git a/tests/dnsmasq.bats b/tests/dnsmasq.bats new file mode 100644 index 0000000..1eaf80d --- /dev/null +++ b/tests/dnsmasq.bats @@ -0,0 +1,114 @@ +#!/usr/bin/env bats +# Seam: LAN DNS (dnsmasq) 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. + +load helpers + +DNSMASQ_DIR="$REPO_ROOT/dankclaw/dnsmasq" +QUADLET="$DNSMASQ_DIR/dnsmasq.container" +CONF="$DNSMASQ_DIR/dnsmasq.conf" +HOSTS="$DNSMASQ_DIR/addn-hosts" + +@test "the dnsmasq quadlet container file exists" { + [ -f "$QUADLET" ] +} + +@test "the dnsmasq 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 dnsmasq 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 "dnsmasq.conf loads the addn-hosts record file" { + grep -q '^addn-hosts=' "$CONF" +} + +@test "every addn-hosts line is a valid IPv4 address plus hostnames" { + local bad=0 line + while IFS= read -r line; do + [ -z "$line" ] && continue + case "$line" in \#*) continue ;; esac + if ! echo "$line" | grep -qE '^([0-9]{1,3}\.){3}[0-9]{1,3}[[:space:]]+[a-z0-9.-]+([[:space:]]+[a-z0-9.-]+)*$'; then + echo "BAD LINE: $line" + bad=1 + fi + done < "$HOSTS" + [ "$bad" -eq 0 ] +} + +@test "every addn-hosts IP is inside the homelab LAN" { + ! awk '!/^[[:space:]]*#/ && NF {print $1}' "$HOSTS" \ + | grep -vE '^(192\.168\.0\.[0-9]{1,3})$' \ + | grep -q . +} + +@test "every addn-hosts name is under franksdank.cloud or franksdank.casa" { + local bad=0 line ip n + while IFS= read -r line; do + [ -z "$line" ] && continue + case "$line" in \#*) continue ;; esac + ip="$(echo "$line" | awk '{print $1}')" + for n in $(echo "$line" | cut -d' ' -f2-); do + echo "$n" | grep -qE '\.(franksdank\.(cloud|casa))$' \ + || { echo "UNEXPECTED TLD on $ip: $n"; bad=1; } + done + done < "$HOSTS" + [ "$bad" -eq 0 ] +} + +# vhost_names — print site-block names under the flat scheme +vhost_names() { + grep -oE '^(http://)?[a-z0-9.-]+\.franksdank\.(cloud|casa)[[:space:]]*\{' "$1" \ + | sed -E 's#^http://##; s/[[:space:]]*\{$//' +} + +# record_ip_for — print the IP a name maps to in addn-hosts +record_ip_for() { + awk -v n="$1" '{ip=$1; for (i=2; i<=NF; i++) if ($i==n) print ip}' "$HOSTS" | head -1 +} + +@test "every dankserver Caddyfile vhost has an A record on 192.168.0.235" { + local names n ip + names="$(vhost_names "$REPO_ROOT/dankserver/Caddyfile")" + [ -n "$names" ] || { echo "no .franksdank vhosts found in dankserver/Caddyfile"; return 1; } + while IFS= read -r n; do + [ -z "$n" ] && continue + ip="$(record_ip_for "$n")" + [ "$ip" = "192.168.0.235" ] || { echo "$n -> '$ip' (want 192.168.0.235)"; return 1; } + done <<< "$names" +} + +@test "every dankclaw Caddyfile vhost has an A record on 192.168.0.143" { + local names n ip + names="$(vhost_names "$REPO_ROOT/dankclaw/Caddyfile")" + [ -n "$names" ] || { echo "no .franksdank vhosts found in dankclaw/Caddyfile"; return 1; } + while IFS= read -r n; do + [ -z "$n" ] && continue + ip="$(record_ip_for "$n")" + [ "$ip" = "192.168.0.143" ] || { echo "$n -> '$ip' (want 192.168.0.143)"; return 1; } + done <<< "$names" +} + +@test "no secret material in the dnsmasq files" { + local f + for f in "$DNSMASQ_DIR"/*; do + has_no_leaked_secret "$f" + done +}