LibrePortal/scripts/network/dns/setup_local_dns.sh
librelad 8b5e02c760 refactor(storage): resolve every app directory through appDir
The main sweep — ~260 call sites across ~100 files move from string
concatenation on a single root to appDir/storageAppDirs/storageAppConfigs.
On a single-root install the resolved paths are identical, so this is a
no-op until a location is registered.

Enumerators were the interesting half. `for d in "$containers_dir"/*/`
appears in the menus, the registry/artifact scanners and the DNS setup —
and a shell glob cannot list a rootless 751 tree at all, which is the
same bug config_find_file.sh already documents in a comment. Routing them
through storageAppDirs (which enumerates as the owning user) fixes that
alongside the multi-root work.

Three places needed judgement rather than substitution:

db_app_scan.sh deletes database rows and port allocations for apps whose
folder is missing, and reaps "empty" app dirs. With a storage location
unmounted, every app on it looks exactly like that. Each of those
branches now gates on appStorageAvailable first — an app on an unplugged
drive is skipped with a notice, never deleted.

instance_create.sh rewrites cloned hooks so an instance touches its own
directory instead of the base app's. Its sed matched ${containers_dir}<type>,
which this sweep just replaced with $(appDir <type>) — so it would have
silently stopped redirecting, and an instance would have written to the
original's files (the adguard auth adapter case its own comment warns
about). Now matches both appDir forms, verified against bare, quoted,
unrelated-app, legacy and prose cases.

peer_shell/peer_pull streamed and extracted relative to the primary root.
Both now use the app's own root, and peer_shell keeps a single-root
fallback since it runs as a restricted SSH shell with no LibrePortal env.

Also fixes a pre-existing bug found on the way: webui_app_config.sh
tested "$containers_dir/frontend/data/last_update", one level short of the
real tree under the libreportal app dir, so the WebUI refresh trigger
after a config update has never once fired.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 04:09:51 +01:00

145 lines
6.6 KiB
Bash

#!/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 <subdomain>.<domain>.
localDnsAppHosts() {
local cfg app up d_idx dom portv sub host
local -a parts
while IFS= read -r cfg; 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_1:-${CFG_ADGUARD_PASSWORD_1:-}}"
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 < <(storageAppConfigs)
}
# 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="$(appDir 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."
}