#!/bin/bash # # Split-horizon ("local") DNS. # # Points every configured domain at the LibrePortal server's LAN IP inside the # self-hosted resolver, so app subdomains resolve to the box on the local # network and hit Traefik directly — with valid Let's Encrypt certs — instead # of bouncing out to the public IP (which most home routers can't hairpin). # # Domain-level, single-host model: every domain -> the one server IP. Traefik # sorts out which app by Host header. Adding apps/domains later is covered # automatically (AdGuard wildcard) or on the next run (Pi-hole host list). # # SAFE BY CONSTRUCTION: every action is idempotent, guarded by an "installed" # check, and cannot corrupt the resolver if a detail is off — # * AdGuard goes through its REST API; a bad URL/cred just fails harmlessly. # * Pi-hole writes ONLY to the supported, mounted /etc/pihole/custom.list, # inside a clearly-marked managed block (never touches /etc/dnsmasq.d). # NOTE: the AdGuard API call and the Pi-hole reload need a live smoke-test — # they can't be exercised without the running containers. # The server's LAN IP (the source address used to reach the network). localDnsServerIp() { local ip ip=$(ip -4 route get 1.1.1.1 2>/dev/null | awk '{for (i=1;i<=NF;i++) if ($i=="src") {print $(i+1); exit}}') [[ -z "$ip" ]] && ip="${public_ip_v4:-}" # last-resort fallback printf '%s' "$ip" } # Configured (non-empty) domains, one per line. localDnsDomains() { local i d for i in 1 2 3 4 5 6 7 8 9; do d="CFG_DOMAIN_${i}"; d="${!d:-}" [[ -n "$d" ]] && printf '%s\n' "$d" done } # Every app host (subdomain.domain) across all apps' Traefik-managed ports — # for resolvers without wildcard support (Pi-hole custom.list). Mirrors the # per-port host rule: subdomain ""/@/root -> apex, else .. localDnsAppHosts() { local cfg app up d_idx dom portv sub host local -a parts for cfg in "${containers_dir}"*/*.config; do [[ -f "$cfg" ]] || continue app=$(basename "$cfg" .config); up=${app^^} d_idx=$(grep -oE "CFG_${up}_DOMAIN=[0-9]+" "$cfg" | head -1 | cut -d= -f2) [[ -z "$d_idx" ]] && continue dom="CFG_DOMAIN_${d_idx}"; dom="${!dom:-}" [[ -z "$dom" ]] && continue while IFS= read -r portv; do IFS='|' read -ra parts <<< "$portv" [[ "${parts[6]:-}" == "true" ]] || continue # Traefik-managed only sub="${parts[10]:-}" if [[ "$sub" == "@" || "$sub" == "root" ]]; then host="$dom" # apex (explicit only) elif [[ -n "$sub" ]]; then host="${sub}.${dom}" else host="${app}.${dom}" # no subdomain set -> app-name default fi printf '%s\n' "$host" done < <(grep -oE "CFG_${up}_PORT_[0-9]+=\"[^\"]*\"" "$cfg" | sed -E 's/^[^"]*"//; s/"$//') done | sort -u } # AdGuard Home: wildcard rewrite per domain (covers every subdomain) + apex, # via the REST API. Idempotent (delete-then-add). Admin port discovered at # runtime via `docker port`, creds from the saved CFG_ADGUARD_* values. localDnsApplyAdguard() { local ip="$1"; shift local user="${CFG_ADGUARD_ADMIN_USER:-admin}" local pass="${CFG_ADGUARD_ADMIN_PASSWORD:-${CFG_ADGUARD_PASSWORD:-}}" local hostport base d entry payload hostport=$(dockerCommandRun "docker port adguard-service 3000/tcp" 2>/dev/null | head -1 | sed 's/.*://') if [[ -z "$hostport" ]]; then isNotice "AdGuard admin port not found (running?). Skipping AdGuard rewrites." return 0 fi base="http://127.0.0.1:${hostport}" for d in "$@"; do for entry in "*.${d}" "${d}"; do payload="{\"domain\":\"${entry}\",\"answer\":\"${ip}\"}" curl -fsS -u "${user}:${pass}" -H 'Content-Type: application/json' \ -X POST "${base}/control/rewrite/delete" -d "$payload" >/dev/null 2>&1 if curl -fsS -u "${user}:${pass}" -H 'Content-Type: application/json' \ -X POST "${base}/control/rewrite/add" -d "$payload" >/dev/null 2>&1; then isSuccessful "AdGuard rewrite: ${entry} -> ${ip}" else isNotice "AdGuard rewrite for ${entry} not applied (check API URL/creds) — safe to retry." fi done done } # Pi-hole: per-host A records in the mounted, supported custom.list, inside a # managed block (no wildcards on Pi-hole; no touching /etc/dnsmasq.d). Reloads # via `pihole restartdns`, falling back to a container restart. localDnsApplyPihole() { local ip="$1" local list="${containers_dir}pihole/pihole-dnsmasq-unbound/custom.list" # mounts to /etc/pihole local b="# >>> libreportal-local >>>" e="# <<< libreportal-local <<<" local hosts tmp h n hosts=$(localDnsAppHosts) if [[ -z "$hosts" ]]; then isNotice "No app hosts for Pi-hole — skipping."; return 0; fi # $list lives under containers/pihole (docker-install-owned): read/write it # via the container-owner helpers; build the new content in a manager /tmp # scratch in between. tmp=$(mktemp) if runFileOp test -f "$list"; then # preserve anything outside our block runFileOp cat "$list" | awk -v b="$b" -v e="$e" '$0==b{skip=1} !skip{print} $0==e{skip=0}' > "$tmp" fi { echo "$b" while IFS= read -r h; do [[ -n "$h" ]] && echo "${ip} ${h}"; done <<< "$hosts" echo "$e" } >> "$tmp" runFileWrite "$list" < "$tmp" rm -f "$tmp" n=$(printf '%s\n' "$hosts" | grep -c .) dockerCommandRun "docker exec pihole-service pihole restartdns" >/dev/null 2>&1 || dockerComposeRestart pihole isSuccessful "Pi-hole custom.list updated: ${n} hosts -> ${ip}" } # Orchestrator — call after a domain change or app install. setupLocalDnsRewrites() { isHeader "Local (split-horizon) DNS" local ip domains ip=$(localDnsServerIp) [[ -z "$ip" ]] && { isNotice "Could not determine the server LAN IP — skipping local DNS."; return 0; } domains=$(localDnsDomains) [[ -z "$domains" ]] && { isNotice "No domains configured (CFG_DOMAIN_*) — skipping local DNS."; return 0; } isNotice "Server IP ${ip} · domains: $(printf '%s ' $domains)" local did=0 if [[ "$(dockerCheckAppInstalled "adguard" "docker")" == "installed" ]]; then localDnsApplyAdguard "$ip" $domains; did=1 fi if [[ "$(dockerCheckAppInstalled "pihole" "docker")" == "installed" ]]; then localDnsApplyPihole "$ip"; did=1 fi [[ "$did" -eq 0 ]] && isNotice "Neither AdGuard nor Pi-hole installed — local DNS skipped." }