diff --git a/scripts/app/app_update_specifics.sh b/scripts/app/app_update_specifics.sh index fafaa74..4a796d3 100755 --- a/scripts/app/app_update_specifics.sh +++ b/scripts/app/app_update_specifics.sh @@ -11,6 +11,8 @@ appUpdateSpecifics() if [[ $CFG_REQUIREMENT_DNS_UPDATER == "true" ]]; then updateDNS $app_name install; fi + # Split-horizon local DNS: app subdomains resolve to the box on the LAN. + declare -F setupLocalDnsRewrites >/dev/null 2>&1 && setupLocalDnsRewrites fi if [[ $app_name == "libreportal" ]]; then diff --git a/scripts/app/containers/adguard/adguard_apply_dns_updater.sh b/scripts/app/containers/adguard/adguard_apply_dns_updater.sh index 92154ed..1e1f79a 100644 --- a/scripts/app/containers/adguard/adguard_apply_dns_updater.sh +++ b/scripts/app/containers/adguard/adguard_apply_dns_updater.sh @@ -9,4 +9,6 @@ appAdguardApplyDnsUpdater() fi updateDNS "adguard" "manual" isSuccessful "/etc/resolv.conf updated to use AdGuard as the host DNS resolver." + # Split-horizon: make app subdomains resolve to the box on the LAN. + declare -F setupLocalDnsRewrites >/dev/null 2>&1 && setupLocalDnsRewrites } diff --git a/scripts/app/containers/pihole/pihole_apply_dns_updater.sh b/scripts/app/containers/pihole/pihole_apply_dns_updater.sh index 1946416..11fc175 100644 --- a/scripts/app/containers/pihole/pihole_apply_dns_updater.sh +++ b/scripts/app/containers/pihole/pihole_apply_dns_updater.sh @@ -9,4 +9,6 @@ appPiholeApplyDnsUpdater() fi updateDNS "pihole" "manual" isSuccessful "/etc/resolv.conf updated to use Pi-hole as the host DNS resolver." + # Split-horizon: make app subdomains resolve to the box on the LAN. + declare -F setupLocalDnsRewrites >/dev/null 2>&1 && setupLocalDnsRewrites } diff --git a/scripts/network/dns/setup_dns_ip.sh b/scripts/network/dns/setup_dns_ip.sh index 8afd95e..205bcbb 100755 --- a/scripts/network/dns/setup_dns_ip.sh +++ b/scripts/network/dns/setup_dns_ip.sh @@ -11,11 +11,10 @@ setupDNSIP() fi fi - # Build variable names based on app_name - dns_host_name_var="CFG_${app_name^^}_HOST_NAME" - - # Access the variables using variable indirection - dns_host_name="${!dns_host_name_var}" - - # UNFINISHED + # STUB: meant to resolve the reachable IP of the given resolver + # (adguard/pihole) into $dns_ip_setup for updateDNS. Not implemented yet, + # so updateDNS falls back to CFG_DNS_SERVER_* upstreams. (Previously read + # CFG__HOST_NAME here, which is unused — removed during the per-port + # subdomain refactor so HOST_NAME has no remaining DNS dependency.) + : } diff --git a/scripts/network/dns/setup_local_dns.sh b/scripts/network/dns/setup_local_dns.sh new file mode 100644 index 0000000..4da40bf --- /dev/null +++ b/scripts/network/dns/setup_local_dns.sh @@ -0,0 +1,140 @@ +#!/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 + tmp=$(sudo mktemp) + if [[ -f "$list" ]]; then # preserve anything outside our block + sudo awk -v b="$b" -v e="$e" '$0==b{skip=1} !skip{print} $0==e{skip=0}' "$list" | sudo tee "$tmp" >/dev/null + fi + { + echo "$b" + while IFS= read -r h; do [[ -n "$h" ]] && echo "${ip} ${h}"; done <<< "$hosts" + echo "$e" + } | sudo tee -a "$tmp" >/dev/null + sudo cp "$tmp" "$list"; sudo 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." +} diff --git a/scripts/source/files/arrays/files_network.sh b/scripts/source/files/arrays/files_network.sh index 14bebe3..638767e 100755 --- a/scripts/source/files/arrays/files_network.sh +++ b/scripts/source/files/arrays/files_network.sh @@ -16,6 +16,7 @@ network_scripts=( "network/display/show_traefik_services.sh" "network/dns/setup_dns_ip.sh" "network/dns/setup_dns.sh" + "network/dns/setup_local_dns.sh" "network/firewall/firewall_initial_setup.sh" "network/firewall/rules/firewall_clear_rules.sh" "network/firewall/rules/firewall_rebuild_from_db.sh"