#!/usr/bin/env bash # Debian 13: switch system name resolution from systemd-resolved to local BIND9. # No package installation, BIND configuration or networking changes. set -Eeuo pipefail umask 077 readonly STATE_DIR=/var/backups/oopen-bind9-direct-resolver readonly CURRENT_FILE="$STATE_DIR/current" readonly RESOLV=/etc/resolv.conf readonly NSS=/etc/nsswitch.conf readonly EXPECTED_NSS='hosts: files myhostname dns' MODE="${1:---check}" case "$MODE" in --check|--apply|--rollback|--help) ;; *) echo "Usage: $0 [--check|--apply|--rollback|--help]" >&2; exit 2;; esac # Colorize status labels on terminals; respect NO_COLOR and redirected output. if [[ -t 1 && ! -v NO_COLOR && "${TERM:-}" != dumb ]]; then GREEN=$'\033[32m' GREY=$'\033[90m' RED=$'\033[31m' YELLOW=$'\033[33m' RESET=$'\033[0m' else GREEN='' GREY='' RED='' YELLOW='' RESET='' fi info() { printf '[ Info ] %s\n' "$*"; } ok() { printf '%s[ ok ]%s %s\n' "$GREEN" "$RESET" "$*"; } skipped() { printf '%s[ skipped ]%s %s\n' "$GREY" "$RESET" "$*"; } failed() { printf '%s[ failed ]%s %s\n' "$RED" "$RESET" "$*" >&2; } # Delimited summary at the end of --check / before an interactive --apply. status_banner(){ local color="$1" message="$2" detail="${3:-}" printf '\n%s------------------------------------------------------------%s\n' "$color" "$RESET" printf '%s%s%s\n' "$color" "$message" "$RESET" [[ -z "$detail" ]] || printf '%s%s%s\n' "$color" "$detail" "$RESET" printf '%s------------------------------------------------------------%s\n' "$color" "$RESET" } # Every invocation finishes with one additional empty output line. trap 'printf "\n"' EXIT fatal() { failed "$*"; exit 1; } echononl(){ printf '%-56s' "$*"; } require_root(){ [[ "${EUID}" -eq 0 ]] || fatal 'Please run as root.'; } need(){ command -v "$1" >/dev/null 2>&1 || fatal "Missing command: $1"; } confirm(){ local answer; read -r -p 'Einverstanden [yes/no, default: no]? ' answer || true; [[ "$answer" == yes ]]; } # Only this script's state file controls rollback. Older manual backups are untouched. # Each run's backup retains the exact file or symlink, along with active/enabled state. state_dir_from_marker(){ [[ -f "$CURRENT_FILE" ]] || return 1 local p IFS= read -r p < "$CURRENT_FILE" || true [[ "$p" == "$STATE_DIR"/run-* && -d "$p" && -f "$p/metadata" ]] || fatal 'Invalid resolver rollback marker.' printf '%s\n' "$p" } is_direct(){ [[ -f "$RESOLV" && ! -L "$RESOLV" ]] && [[ "$(awk '$1=="nameserver" {print $2}' "$RESOLV")" == '127.0.0.1' ]] && grep -Eq '^[[:space:]]*hosts:[[:space:]]*files[[:space:]]+myhostname[[:space:]]+dns[[:space:]]*$' "$NSS" } is_fully_switched(){ is_direct && ! systemctl is-active --quiet systemd-resolved && [[ "$(systemctl is-enabled systemd-resolved 2>/dev/null || true)" == disabled ]] } # BIND must reject a known bogus signature, not merely answer normal queries. preflight(){ echononl 'BIND9 on 127.0.0.1:53' if dig @127.0.0.1 oopen.de A +time=3 +tries=1 +short | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$'; then ok 'reachable'; else echo; failed 'Local BIND9 does not resolve oopen.de'; return 1; fi echononl 'DNSSEC positive test (AD flag)' local reply reply=$(dig @127.0.0.1 cloudflare.com A +dnssec +adflag +time=5 +tries=1) if grep -q 'status: NOERROR' <<< "$reply" && grep -Eq 'flags:.*[[:space:]]ad[;[:space:]]' <<< "$reply"; then ok 'validated'; else echo; failed 'BIND9 DNSSEC positive test failed'; return 1; fi echononl 'DNSSEC negative test (SERVFAIL)' reply=$(dig @127.0.0.1 www.dnssec-failed.org A +time=5 +tries=1) if grep -q 'status: SERVFAIL' <<< "$reply"; then ok 'rejected as expected'; else echo; failed 'BIND9 does not reject invalid DNSSEC test zone'; return 1; fi } show_check(){ require_root need dig; need systemctl; need getent printf '\n=== DNS resolver status ===\n' printf 'resolv.conf: '; if [[ -L "$RESOLV" ]]; then readlink "$RESOLV"; else echo 'regular file'; fi grep -E '^[[:space:]]*(nameserver|search|options)[[:space:]]' "$RESOLV" || true grep -E '^hosts:' "$NSS" || true printf 'systemd-resolved active: %s\n' "$(systemctl is-active systemd-resolved 2>/dev/null || true)" printf 'systemd-resolved enabled: %s\n' "$(systemctl is-enabled systemd-resolved 2>/dev/null || true)" local readiness=0 if preflight; then readiness=1; fi if is_fully_switched; then if (( readiness )); then status_banner "$GREY" 'Umstellung auf BIND9 bereits durchgeführt.' else status_banner "$YELLOW" 'Umstellung bereits durchgeführt, aber DNS-Prüfung fehlgeschlagen.' fi elif [[ -f "$CURRENT_FILE" ]]; then status_banner "$YELLOW" 'Umstellung kann nicht ausgeführt werden.' \ 'Ein vorheriger Rollback-Stand ist vorhanden; zunächst prüfen oder zurückbauen.' elif (( readiness )); then status_banner "$GREEN" 'Umstellung kann durchgeführt werden.' else status_banner "$YELLOW" 'Umstellung kann nicht ausgeführt werden.' \ 'BIND9-/DNSSEC-Voraussetzungen nicht erfüllt; bitte Fehler prüfen.' fi if [[ -f "$CURRENT_FILE" ]]; then printf '\n=== Rollback information ===\n' info 'Backup from before the last --apply is available' info "Backup: $(state_dir_from_marker)" info "Restore with: $0 --rollback" fi } restore_files(){ local path="$1" [[ -d "$path" && -e "$path/nsswitch.conf" ]] || fatal 'Incomplete backup: NSS' [[ -e "$path/resolv.conf" || -L "$path/resolv.conf" ]] || fatal 'Incomplete backup: resolv.conf' cp -a --remove-destination "$path/nsswitch.conf" "$NSS" cp -a --remove-destination "$path/resolv.conf" "$RESOLV" } restore_services(){ local path="$1" enabled active enabled=$(sed -n 's/^resolved_enabled=//p' "$path/metadata") active=$(sed -n 's/^resolved_active=//p' "$path/metadata") case "$enabled" in enabled|enabled-runtime) systemctl enable systemd-resolved >/dev/null ;; disabled) systemctl disable systemd-resolved >/dev/null ;; static|indirect|masked|alias|'') info "Previous enabled state '$enabled'; no enable-state change" ;; *) info "Unrecognized previous enabled state '$enabled'; no enable-state change" ;; esac case "$active" in active) systemctl start systemd-resolved ;; inactive|failed) systemctl stop systemd-resolved ;; *) info "Previous active state '$active'; no service-state change" ;; esac } rollback_to(){ local path="$1" info "Restoring config from $path" # Stop resolved first only when it was previously not active; otherwise start after files. restore_files "$path" restore_services "$path" ok 'Configuration and resolved service state restored' } apply(){ require_root for cmd in dig getent systemctl cp mv mktemp grep awk sed date; do need "$cmd"; done [[ -f "$NSS" ]] || fatal "$NSS missing" [[ -e "$RESOLV" || -L "$RESOLV" ]] || fatal "$RESOLV missing" [[ ! -f "$CURRENT_FILE" ]] || { if is_fully_switched; then status_banner "$GREY" 'Umstellung auf BIND9 bereits durchgeführt.'; skipped 'No changes'; exit 0; fi status_banner "$YELLOW" 'Umstellung kann nicht ausgeführt werden.' 'Vorheriger Rollback-Stand vorhanden.' fatal 'An earlier script run has rollback state. Use --check or --rollback first.'; } if is_fully_switched; then status_banner "$GREY" 'Umstellung auf BIND9 bereits durchgeführt.'; skipped 'No changes'; exit 0; fi info 'Preflight checks (no changes)' if ! preflight; then status_banner "$YELLOW" 'Umstellung kann nicht ausgeführt werden.' 'BIND9-/DNSSEC-Voraussetzungen nicht erfüllt.' return 1 fi status_banner "$GREEN" 'Umstellung kann durchgeführt werden.' printf '\nPlanned changes:\n hosts: files myhostname dns\n resolv.conf: nameserver 127.0.0.1\n stop and disable systemd-resolved\n' info 'Network interfaces, routes, BIND9 and MTA are not changed.' confirm || { skipped 'Abbruch durch User'; exit 0; } mkdir -p "$STATE_DIR" chmod 700 "$STATE_DIR" local path timestamp timestamp=$(date +%Y%m%d-%H%M%S) path=$(mktemp -d "$STATE_DIR/run-${timestamp}-XXXXXX") cp -a "$RESOLV" "$path/resolv.conf" cp -a "$NSS" "$path/nsswitch.conf" { printf 'resolved_active=%s\n' "$(systemctl is-active systemd-resolved 2>/dev/null || true)" printf 'resolved_enabled=%s\n' "$(systemctl is-enabled systemd-resolved 2>/dev/null || true)" } > "$path/metadata" printf '%s\n' "$path" > "$CURRENT_FILE" info "Backup: $path" # Roll back this transaction automatically if a command or signal fails. trap 'rc=$?; trap - ERR INT TERM; failed "Apply failed (exit $rc). Rolling back"; rollback_to "$path" || failed "Automatic rollback failed; backup: $path"; exit "$rc"' ERR trap 'exit 130' INT trap 'exit 143' TERM if ! grep -Eq '^[[:space:]]*hosts:[[:space:]]*files[[:space:]]+myhostname[[:space:]]+dns[[:space:]]*$' "$NSS"; then sed -i -E '/^[[:space:]]*hosts:/c\hosts: files myhostname dns' "$NSS" else skipped 'NSS already configured'; fi if [[ ! -f "$RESOLV" || -L "$RESOLV" ]] || ! grep -Eq '^nameserver[[:space:]]+127\.0\.0\.1$' "$RESOLV"; then local search_domain="" search_domain=$(awk '$1=="search" { $1=""; sub(/^[[:space:]]+/, ""); print; exit }' "$RESOLV" 2>/dev/null || true) rm -f "$RESOLV" { printf '# Local BIND9 recursive resolver; DNSSEC validated by BIND9.\n' printf 'nameserver 127.0.0.1\noptions edns0 trust-ad\n' [[ -z "$search_domain" ]] || printf 'search %s\n' "$search_domain" } > "$RESOLV" chmod 644 "$RESOLV" else skipped 'resolv.conf already uses local BIND9'; fi echononl 'NSS forward resolution' getent ahostsv4 oopen.de >/dev/null && ok 'works' || { echo; false; } echononl 'NSS reverse resolution' getent hosts 2607:f8b0:4864:20::72e >/dev/null && ok 'works' || { echo; false; } echononl 'Resolver configuration' is_direct && ok 'direct BIND9' || { echo; false; } systemctl stop systemd-resolved systemctl disable systemd-resolved >/dev/null echononl 'systemd-resolved disabled' is_fully_switched && ok 'yes' || { echo; false; } preflight trap - ERR INT TERM ok 'Switch completed successfully' info "Rollback available: $0 --rollback" } rollback(){ require_root local path path=$(state_dir_from_marker) || fatal 'No rollback state from this script exists' info "Rollback backup: $path" confirm || { skipped 'Abbruch durch User'; exit 0; } rollback_to "$path" rm -f "$CURRENT_FILE" ok 'Rollback completed; preserved backup remains in place' } case "$MODE" in --help) cat <<'HELP' Usage: switch-resolver-to-bind9.sh [--check|--apply|--rollback|--help] --check read-only checks (default) --apply verify BIND9 DNSSEC; back up; switch DNS/NSS; stop+disable resolved --rollback restore files and resolved service state saved before last --apply --help this help Notes: run as root; requires BIND9 already working; no package changes. HELP ;; --check) show_check ;; --apply) apply ;; --rollback) rollback ;; esac