#!/bin/bash ################################################################################ # Script Name: install-unbound.sh # Version: 1.1 # Description: Install and configure Unbound recursive DNS resolver with # DNSSEC validation, DNS-over-TLS forwarding, local zones, # unbound-control, and optional NSD stub-zone pairing # # Author: Phil Connor # Contact: contact@mylinux.work # Website: https://mylinux.work # License: MIT # Date: 2026-03-31 # # Supported OS: # - Ubuntu / Debian # - RHEL / AlmaLinux / Rocky Linux / Fedora # # Usage: # sudo ./install-unbound.sh # sudo ./install-unbound.sh --forward cloudflare --dot # sudo ./install-unbound.sh --forward google --local-zone home.lab # sudo ./install-unbound.sh --nsd-stub example.com --nsd-port 5353 # sudo ./install-unbound.sh --uninstall # ./install-unbound.sh --dry-run # ################################################################################ set -euo pipefail # ============================================================================ # CONFIGURATION # ============================================================================ readonly VERSION="1.0" readonly SCRIPT_NAME="${0##*/}" readonly LOG_FILE="/var/log/unbound-install.log" # Defaults MODE="recursive" # recursive (root hints) or forward FORWARDER="" # cloudflare, google, quad9, or custom IP USE_DOT=false # DNS-over-TLS for forwarding LOCAL_ZONES=() # Local zone names to create LOCAL_ENTRIES=() # Individual local-data entries NSD_STUB_ZONES=() # Zones to stub to NSD NSD_PORT="5353" # Port NSD listens on LISTEN_IP="127.0.0.1" # Default: localhost only LISTEN_ALL=false # Listen on all interfaces THREADS="" # Auto-detect from CPU cores CACHE_SIZE="" # Auto-detect from RAM UNINSTALL=false DRY_RUN=false # Paths (set after OS detection) UNBOUND_CONF="" UNBOUND_CONF_DIR="" UNBOUND_USER="" TRUST_ANCHOR="" ROOT_HINTS="" TLS_BUNDLE="" # OS detection OS="" OS_FAMILY="" # ============================================================================ # COLOR OUTPUT # ============================================================================ RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' CYAN='\033[0;36m' NC='\033[0m' print_success() { echo -e "${GREEN} ✓ $1${NC}"; } print_error() { echo -e "${RED} ✗ $1${NC}" >&2; } print_warning() { echo -e "${YELLOW} ⚠ $1${NC}"; } print_info() { echo -e " → $1"; } print_header() { echo -e "\n${CYAN}━━━ $1 ━━━${NC}"; } # ============================================================================ # LOGGING # ============================================================================ log() { local msg="[$(date '+%Y-%m-%d %H:%M:%S')] $1" if [[ "$DRY_RUN" == "false" ]] && [[ -w "$(dirname "$LOG_FILE")" || -w "$LOG_FILE" ]]; then echo "$msg" >> "$LOG_FILE" fi echo "$msg" } log_error() { local msg="[$(date '+%Y-%m-%d %H:%M:%S')] ERROR: $1" if [[ "$DRY_RUN" == "false" ]] && [[ -w "$(dirname "$LOG_FILE")" || -w "$LOG_FILE" ]]; then echo "$msg" >> "$LOG_FILE" fi print_error "$1" } # ============================================================================ # HELPER FUNCTIONS # ============================================================================ show_help() { cat </dev/null; then print_warning "systemd-resolved is running and binds to port 53" if [[ "$DRY_RUN" == "true" ]]; then print_info "[DRY RUN] Would disable systemd-resolved" return fi log "Disabling systemd-resolved to free port 53" systemctl stop systemd-resolved systemctl disable systemd-resolved if [[ -L /etc/resolv.conf ]]; then rm -f /etc/resolv.conf echo "nameserver 1.1.1.1" > /etc/resolv.conf fi print_success "Disabled systemd-resolved" else print_info "systemd-resolved not running — no action needed" fi } # ============================================================================ # INSTALL UNBOUND # ============================================================================ install_unbound() { print_header "Installing Unbound" if command -v unbound &>/dev/null; then print_info "Unbound is already installed" return fi if [[ "$DRY_RUN" == "true" ]]; then print_info "[DRY RUN] Would install unbound packages" return fi log "Installing Unbound packages" case "$OS_FAMILY" in debian) apt-get update -qq apt-get install -y -qq unbound unbound-host dns-root-data > /dev/null ;; rhel) if [[ "$OS" != "fedora" ]]; then dnf install -y -q epel-release > /dev/null 2>&1 || true fi dnf install -y -q unbound > /dev/null ;; esac print_success "Unbound installed" } # ============================================================================ # AUTO-DETECT TUNING # ============================================================================ detect_tuning() { if [[ -z "$THREADS" ]]; then THREADS=$(nproc 2>/dev/null || echo 2) fi if [[ -z "$CACHE_SIZE" ]]; then local total_mb total_mb=$(awk '/MemTotal/ {print int($2/1024)}' /proc/meminfo 2>/dev/null || echo 1024) if (( total_mb <= 1024 )); then CACHE_SIZE="16" elif (( total_mb <= 2048 )); then CACHE_SIZE="32" elif (( total_mb <= 4096 )); then CACHE_SIZE="64" else CACHE_SIZE="128" fi fi # Slabs must be power of 2 >= threads local slabs=2 while (( slabs < THREADS )); do slabs=$((slabs * 2)) done SLAB_COUNT=$slabs local rrset_size=$((CACHE_SIZE * 2)) log "Tuning: threads=$THREADS, msg-cache=${CACHE_SIZE}m, rrset-cache=${rrset_size}m, slabs=$SLAB_COUNT" } # ============================================================================ # SETUP ROOT HINTS # ============================================================================ setup_root_hints() { if [[ "$MODE" != "recursive" ]]; then return fi print_info "Setting up root hints" if [[ "$DRY_RUN" == "true" ]]; then print_info "[DRY RUN] Would download root hints" return fi local hints_dir hints_dir=$(dirname "$ROOT_HINTS") mkdir -p "$hints_dir" if wget -qO "$ROOT_HINTS.tmp" https://www.internic.net/domain/named.cache 2>/dev/null; then mv "$ROOT_HINTS.tmp" "$ROOT_HINTS" chown "$UNBOUND_USER":"$UNBOUND_USER" "$ROOT_HINTS" 2>/dev/null || true print_success "Root hints downloaded" else print_warning "Could not download root hints — using package default" fi } # ============================================================================ # SETUP TRUST ANCHOR # ============================================================================ setup_trust_anchor() { print_info "Setting up DNSSEC trust anchor" if [[ "$DRY_RUN" == "true" ]]; then print_info "[DRY RUN] Would set up DNSSEC trust anchor" return fi local anchor_dir anchor_dir=$(dirname "$TRUST_ANCHOR") mkdir -p "$anchor_dir" chown "$UNBOUND_USER":"$UNBOUND_USER" "$anchor_dir" if command -v unbound-anchor &>/dev/null; then unbound-anchor -a "$TRUST_ANCHOR" 2>/dev/null || true chown "$UNBOUND_USER":"$UNBOUND_USER" "$TRUST_ANCHOR" 2>/dev/null || true print_success "DNSSEC trust anchor configured" else print_warning "unbound-anchor not found — DNSSEC trust anchor not configured" fi } # ============================================================================ # SETUP UNBOUND-CONTROL # ============================================================================ setup_control() { print_info "Setting up unbound-control" if [[ "$DRY_RUN" == "true" ]]; then print_info "[DRY RUN] Would generate unbound-control keys" return fi if [[ -f /etc/unbound/unbound_control.key ]]; then print_info "unbound-control keys already exist" return fi if command -v unbound-control-setup &>/dev/null; then unbound-control-setup > /dev/null 2>&1 print_success "unbound-control keys generated" else print_warning "unbound-control-setup not found" fi } # ============================================================================ # GENERATE CONFIGURATION # ============================================================================ generate_config() { print_header "Generating Configuration" local rrset_size=$((CACHE_SIZE * 2)) local listen_addr="$LISTEN_IP" if [[ "$LISTEN_ALL" == "true" ]]; then listen_addr="0.0.0.0" fi if [[ "$DRY_RUN" == "true" ]]; then print_info "[DRY RUN] Would write $UNBOUND_CONF" print_info " Listen: $listen_addr" print_info " Mode: $MODE" [[ -n "$FORWARDER" ]] && print_info " Forwarder: $FORWARDER (DoT: $USE_DOT)" print_info " Threads: $THREADS, Cache: ${CACHE_SIZE}m" [[ ${#LOCAL_ZONES[@]} -gt 0 ]] && print_info " Local zones: ${LOCAL_ZONES[*]}" [[ ${#NSD_STUB_ZONES[@]} -gt 0 ]] && print_info " NSD stub zones: ${NSD_STUB_ZONES[*]} (port $NSD_PORT)" return fi # Backup existing config if [[ -f "$UNBOUND_CONF" ]]; then cp "$UNBOUND_CONF" "${UNBOUND_CONF}.bak.$(date +%Y%m%d%H%M%S)" print_info "Backed up existing config" fi mkdir -p "$UNBOUND_CONF_DIR" # ── Main config ── cat > "$UNBOUND_CONF" <> "$UNBOUND_CONF" <> "$UNBOUND_CONF" done cat >> "$UNBOUND_CONF" <> "$UNBOUND_CONF" echo "" >> "$UNBOUND_CONF" fi # TLS bundle (for DoT) if [[ "$USE_DOT" == "true" ]]; then echo " tls-cert-bundle: \"$TLS_BUNDLE\"" >> "$UNBOUND_CONF" echo "" >> "$UNBOUND_CONF" fi # Include directory echo " include: \"$UNBOUND_CONF_DIR/*.conf\"" >> "$UNBOUND_CONF" echo "" >> "$UNBOUND_CONF" # Remote control cat >> "$UNBOUND_CONF" < "$conf_file" <> "$conf_file" <> "$conf_file" <> "$conf_file" <> "$conf_file" <> "$conf_file" <> "$conf_file" <> "$conf_file" < "$conf_file" <> "$conf_file" done for entry in "${LOCAL_ENTRIES[@]}"; do echo " local-data: \"$entry\"" >> "$conf_file" done print_success "Local zones config written (${#LOCAL_ZONES[@]} zones, ${#LOCAL_ENTRIES[@]} records)" } # ============================================================================ # NSD STUB ZONE CONFIGURATION # ============================================================================ generate_nsd_stub_config() { local conf_file="$UNBOUND_CONF_DIR/nsd-stubs.conf" cat > "$conf_file" <> "$conf_file" </dev/null && ufw status | grep -q "active"; then ufw allow 53/tcp > /dev/null 2>&1 ufw allow 53/udp > /dev/null 2>&1 print_success "Opened ports 53/tcp and 53/udp (ufw)" elif command -v firewall-cmd &>/dev/null && systemctl is-active --quiet firewalld; then firewall-cmd --permanent --add-service=dns > /dev/null 2>&1 firewall-cmd --reload > /dev/null 2>&1 print_success "Opened DNS ports (firewalld)" else print_warning "No active firewall detected — skipping" fi } # ============================================================================ # START AND VALIDATE # ============================================================================ validate_and_start() { print_header "Validating and Starting Unbound" if [[ "$DRY_RUN" == "true" ]]; then print_info "[DRY RUN] Would validate config and start unbound" return fi # Create pid directory mkdir -p /run/unbound chown "$UNBOUND_USER":"$UNBOUND_USER" /run/unbound # Set ownership chown -R "$UNBOUND_USER":"$UNBOUND_USER" /etc/unbound/ 2>/dev/null || true chmod 640 "$UNBOUND_CONF" # Validate if unbound-checkconf "$UNBOUND_CONF" > /dev/null 2>&1; then print_success "Configuration validated (unbound-checkconf)" else print_error "Configuration validation failed:" unbound-checkconf "$UNBOUND_CONF" exit 1 fi # Enable and start systemctl enable unbound > /dev/null 2>&1 systemctl restart unbound if systemctl is-active --quiet unbound; then print_success "Unbound is running" else print_error "Unbound failed to start" journalctl -u unbound --no-pager -n 10 exit 1 fi # Quick test sleep 1 if dig @127.0.0.1 example.com A +short +time=5 > /dev/null 2>&1; then print_success "DNS resolution test passed" else print_warning "DNS resolution test failed — check logs with: journalctl -u unbound" fi } # ============================================================================ # UNINSTALL # ============================================================================ do_uninstall() { print_header "Uninstalling Unbound" if [[ "$DRY_RUN" == "true" ]]; then print_info "[DRY RUN] Would remove Unbound and configuration" return fi systemctl stop unbound 2>/dev/null || true systemctl disable unbound 2>/dev/null || true case "$OS_FAMILY" in debian) apt-get remove --purge -y -qq unbound unbound-host dns-root-data > /dev/null 2>&1 || true apt-get autoremove -y -qq > /dev/null 2>&1 || true ;; rhel) dnf remove -y -q unbound > /dev/null 2>&1 || true ;; esac rm -rf /etc/unbound rm -f /var/lib/unbound/root.key print_success "Unbound removed" log "Unbound uninstalled" } # ============================================================================ # SUMMARY # ============================================================================ print_summary() { local rrset_size=$((CACHE_SIZE * 2)) print_header "Installation Complete" echo "" echo " Unbound recursive DNS resolver is running." echo "" echo " Config: $UNBOUND_CONF" echo " Config dir: $UNBOUND_CONF_DIR" echo " Listen: ${LISTEN_ALL:+0.0.0.0}${LISTEN_ALL:-$LISTEN_IP}:53" echo " Mode: $MODE" [[ -n "$FORWARDER" ]] && echo " Forwarder: $FORWARDER (DoT: $USE_DOT)" echo " Threads: $THREADS" echo " Cache: msg=${CACHE_SIZE}m, rrset=${rrset_size}m" echo " DNSSEC: enabled" [[ ${#LOCAL_ZONES[@]} -gt 0 ]] && echo " Local zones: ${LOCAL_ZONES[*]}" [[ ${#NSD_STUB_ZONES[@]} -gt 0 ]] && echo " NSD stubs: ${NSD_STUB_ZONES[*]} (port $NSD_PORT)" echo " Log: $LOG_FILE" echo "" echo " Useful commands:" echo " sudo unbound-control status" echo " sudo unbound-control stats_noreset" echo " dig @127.0.0.1 example.com A" echo " sudo journalctl -u unbound -f" echo "" } # ============================================================================ # PARSE ARGUMENTS # ============================================================================ ALLOW_NETS=() parse_args() { while [[ $# -gt 0 ]]; do case "$1" in --recursive) MODE="recursive" shift ;; --forward) MODE="forward" FORWARDER="${2:?'--forward requires a provider (cloudflare, google, quad9, or IP)'}" shift 2 ;; --dot) USE_DOT=true shift ;; --listen) LISTEN_IP="${2:?'--listen requires an IP address'}" shift 2 ;; --listen-all) LISTEN_ALL=true shift ;; --allow-net) ALLOW_NETS+=("${2:?'--allow-net requires a CIDR'}") shift 2 ;; --local-zone) LOCAL_ZONES+=("${2:?'--local-zone requires a zone name'}") shift 2 ;; --local-data) LOCAL_ENTRIES+=("${2:?'--local-data requires a record'}") shift 2 ;; --nsd-stub) NSD_STUB_ZONES+=("${2:?'--nsd-stub requires a zone name'}") shift 2 ;; --nsd-port) NSD_PORT="${2:?'--nsd-port requires a port number'}" shift 2 ;; --threads) THREADS="${2:?'--threads requires a number'}" shift 2 ;; --cache-size) CACHE_SIZE="${2:?'--cache-size requires a size in MB'}" shift 2 ;; --uninstall) UNINSTALL=true shift ;; --dry-run) DRY_RUN=true shift ;; -h|--help) show_help ;; --version) show_version ;; *) log_error "Unknown option: $1" echo "Use --help for usage information" exit 1 ;; esac done # Validate if [[ "$USE_DOT" == "true" && "$MODE" != "forward" ]]; then log_error "--dot requires --forward (DoT is for forwarding to upstream)" exit 1 fi } # ============================================================================ # MAIN # ============================================================================ main() { parse_args "$@" echo "" echo " ╔══════════════════════════════════════════╗" echo " ║ Unbound Installer v$VERSION ║" echo " ║ https://mylinux.work ║" echo " ╚══════════════════════════════════════════╝" echo "" [[ "$DRY_RUN" == "true" ]] && print_warning "DRY RUN MODE — no changes will be made" # Root check (skip for dry-run) if [[ "$DRY_RUN" == "false" ]]; then check_root fi detect_os if [[ "$UNINSTALL" == "true" ]]; then do_uninstall exit 0 fi detect_tuning disable_resolved install_unbound setup_trust_anchor setup_root_hints setup_control generate_config configure_firewall validate_and_start print_summary log "Installation complete" } main "$@"