a1a17e81a1
Includes updated JS challenge scripts with Claude-User whitelist, same-site referer bypass, Blackbox-Exporter allowed bot, and all new exporters, cheat sheets, and automation scripts.
952 lines
28 KiB
Bash
952 lines
28 KiB
Bash
#!/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 <<EOF
|
|
Usage: $SCRIPT_NAME [OPTIONS]
|
|
|
|
Install and configure Unbound recursive DNS resolver.
|
|
|
|
MODE OPTIONS:
|
|
--recursive Full recursion from root hints (default)
|
|
--forward PROVIDER Forward to upstream: cloudflare, google, quad9, or IP
|
|
--dot Use DNS-over-TLS for forwarding (requires --forward)
|
|
|
|
NETWORK:
|
|
--listen IP IP to listen on (default: 127.0.0.1)
|
|
--listen-all Listen on all interfaces (0.0.0.0)
|
|
--allow-net CIDR Allow queries from network (repeatable, default: 127.0.0.0/8)
|
|
|
|
LOCAL ZONES:
|
|
--local-zone NAME Create a local zone (repeatable)
|
|
--local-data "RECORD" Add a local-data entry (repeatable)
|
|
Format: "hostname.zone. A 10.0.1.10"
|
|
|
|
NSD INTEGRATION:
|
|
--nsd-stub ZONE Add stub-zone for NSD (repeatable)
|
|
--nsd-port PORT NSD port (default: 5353)
|
|
|
|
TUNING:
|
|
--threads N Number of threads (default: auto-detect CPU cores)
|
|
--cache-size SIZE Message cache size in MB (default: auto-detect)
|
|
|
|
ACTIONS:
|
|
--uninstall Remove Unbound and configuration
|
|
--dry-run Show what would be done without executing
|
|
-h, --help Show this help message
|
|
--version Show version
|
|
|
|
EXAMPLES:
|
|
$SCRIPT_NAME
|
|
$SCRIPT_NAME --forward cloudflare --dot
|
|
$SCRIPT_NAME --forward google --listen-all --allow-net 10.0.0.0/8
|
|
$SCRIPT_NAME --local-zone home.lab --local-data "nas.home.lab. A 192.168.1.100"
|
|
$SCRIPT_NAME --nsd-stub example.com --nsd-port 5353
|
|
$SCRIPT_NAME --uninstall
|
|
|
|
EOF
|
|
exit 0
|
|
}
|
|
|
|
show_version() {
|
|
echo "$SCRIPT_NAME version $VERSION"
|
|
exit 0
|
|
}
|
|
|
|
check_root() {
|
|
if [[ $EUID -ne 0 ]]; then
|
|
print_error "This script must be run as root (use sudo)"
|
|
exit 1
|
|
fi
|
|
}
|
|
|
|
# ============================================================================
|
|
# OS DETECTION
|
|
# ============================================================================
|
|
|
|
detect_os() {
|
|
if [[ ! -f /etc/os-release ]]; then
|
|
log_error "Cannot detect OS — /etc/os-release not found"
|
|
exit 1
|
|
fi
|
|
|
|
OS=$({ grep ^ID= /etc/os-release || true; } | cut -d= -f2 | tr -d '"' | tr '[:upper:]' '[:lower:]')
|
|
|
|
case "$OS" in
|
|
ubuntu|debian)
|
|
OS_FAMILY="debian"
|
|
UNBOUND_CONF="/etc/unbound/unbound.conf"
|
|
UNBOUND_CONF_DIR="/etc/unbound/unbound.conf.d"
|
|
UNBOUND_USER="unbound"
|
|
TRUST_ANCHOR="/var/lib/unbound/root.key"
|
|
ROOT_HINTS="/usr/share/dns/root.hints"
|
|
TLS_BUNDLE="/etc/ssl/certs/ca-certificates.crt"
|
|
;;
|
|
rhel|centos|rocky|almalinux|fedora)
|
|
OS_FAMILY="rhel"
|
|
UNBOUND_CONF="/etc/unbound/unbound.conf"
|
|
UNBOUND_CONF_DIR="/etc/unbound/conf.d"
|
|
UNBOUND_USER="unbound"
|
|
TRUST_ANCHOR="/var/lib/unbound/root.key"
|
|
ROOT_HINTS="/etc/unbound/root.hints"
|
|
TLS_BUNDLE="/etc/pki/tls/certs/ca-bundle.crt"
|
|
;;
|
|
*)
|
|
log_error "Unsupported OS: $OS"
|
|
echo "Supported: Ubuntu, Debian, RHEL, AlmaLinux, Rocky Linux, Fedora"
|
|
exit 1
|
|
;;
|
|
esac
|
|
|
|
log "Detected OS: $OS (family: $OS_FAMILY)"
|
|
}
|
|
|
|
# ============================================================================
|
|
# SYSTEMD-RESOLVED HANDLING
|
|
# ============================================================================
|
|
|
|
disable_resolved() {
|
|
if [[ "$OS" != "ubuntu" ]]; then
|
|
return
|
|
fi
|
|
|
|
if systemctl is-active --quiet systemd-resolved 2>/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" <<CONF
|
|
# Unbound configuration — generated by $SCRIPT_NAME v$VERSION
|
|
# Date: $(date '+%Y-%m-%d %H:%M:%S')
|
|
|
|
server:
|
|
# Network
|
|
interface: $listen_addr
|
|
port: 53
|
|
do-ip4: yes
|
|
do-ip6: no
|
|
do-udp: yes
|
|
do-tcp: yes
|
|
|
|
# Access control
|
|
access-control: 127.0.0.0/8 allow
|
|
CONF
|
|
|
|
if [[ "$LISTEN_ALL" == "true" ]]; then
|
|
cat >> "$UNBOUND_CONF" <<CONF
|
|
access-control: 10.0.0.0/8 allow
|
|
access-control: 172.16.0.0/12 allow
|
|
access-control: 192.168.0.0/16 allow
|
|
CONF
|
|
fi
|
|
|
|
# Add any extra --allow-net entries
|
|
for net in "${ALLOW_NETS[@]:-}"; do
|
|
[[ -n "$net" ]] && echo " access-control: $net allow" >> "$UNBOUND_CONF"
|
|
done
|
|
|
|
cat >> "$UNBOUND_CONF" <<CONF
|
|
|
|
# DNSSEC
|
|
auto-trust-anchor-file: "$TRUST_ANCHOR"
|
|
|
|
# Security
|
|
hide-identity: yes
|
|
hide-version: yes
|
|
harden-glue: yes
|
|
harden-dnssec-stripped: yes
|
|
harden-referral-path: yes
|
|
use-caps-for-id: yes
|
|
qname-minimisation: yes
|
|
aggressive-nsec: yes
|
|
|
|
# Cache
|
|
cache-min-ttl: 300
|
|
cache-max-ttl: 86400
|
|
prefetch: yes
|
|
prefetch-key: yes
|
|
serve-expired: yes
|
|
serve-expired-ttl: 86400
|
|
|
|
# Performance
|
|
num-threads: $THREADS
|
|
msg-cache-slabs: $SLAB_COUNT
|
|
rrset-cache-slabs: $SLAB_COUNT
|
|
infra-cache-slabs: $SLAB_COUNT
|
|
key-cache-slabs: $SLAB_COUNT
|
|
msg-cache-size: ${CACHE_SIZE}m
|
|
rrset-cache-size: ${rrset_size}m
|
|
outgoing-range: 4096
|
|
num-queries-per-thread: 2048
|
|
so-reuseport: yes
|
|
|
|
# Logging
|
|
verbosity: 1
|
|
log-queries: no
|
|
log-replies: no
|
|
logfile: ""
|
|
|
|
# Private addresses
|
|
private-address: 10.0.0.0/8
|
|
private-address: 172.16.0.0/12
|
|
private-address: 192.168.0.0/16
|
|
private-address: 169.254.0.0/16
|
|
private-address: fd00::/8
|
|
private-address: fe80::/10
|
|
|
|
# Runtime
|
|
username: "$UNBOUND_USER"
|
|
directory: "/etc/unbound"
|
|
chroot: ""
|
|
pidfile: "/run/unbound/unbound.pid"
|
|
|
|
CONF
|
|
|
|
# Root hints (recursive mode only)
|
|
if [[ "$MODE" == "recursive" ]]; then
|
|
echo " root-hints: \"$ROOT_HINTS\"" >> "$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
|
|
remote-control:
|
|
control-enable: yes
|
|
control-interface: 127.0.0.1
|
|
control-port: 8953
|
|
CONF
|
|
|
|
print_success "Main config written to $UNBOUND_CONF"
|
|
|
|
# ── Forwarding config ──
|
|
if [[ "$MODE" == "forward" && -n "$FORWARDER" ]]; then
|
|
generate_forward_config
|
|
fi
|
|
|
|
# ── Local zones config ──
|
|
if [[ ${#LOCAL_ZONES[@]} -gt 0 || ${#LOCAL_ENTRIES[@]} -gt 0 ]]; then
|
|
generate_local_zones_config
|
|
fi
|
|
|
|
# ── NSD stub zones config ──
|
|
if [[ ${#NSD_STUB_ZONES[@]} -gt 0 ]]; then
|
|
generate_nsd_stub_config
|
|
fi
|
|
}
|
|
|
|
# ============================================================================
|
|
# FORWARDING CONFIGURATION
|
|
# ============================================================================
|
|
|
|
generate_forward_config() {
|
|
local conf_file="$UNBOUND_CONF_DIR/forwarding.conf"
|
|
local dot_flag=""
|
|
[[ "$USE_DOT" == "true" ]] && dot_flag="yes" || dot_flag="no"
|
|
|
|
cat > "$conf_file" <<CONF
|
|
# Forwarding configuration — generated by $SCRIPT_NAME
|
|
CONF
|
|
|
|
case "$FORWARDER" in
|
|
cloudflare)
|
|
if [[ "$USE_DOT" == "true" ]]; then
|
|
cat >> "$conf_file" <<CONF
|
|
forward-zone:
|
|
name: "."
|
|
forward-tls-upstream: yes
|
|
forward-addr: 1.1.1.1@853#cloudflare-dns.com
|
|
forward-addr: 1.0.0.1@853#cloudflare-dns.com
|
|
CONF
|
|
else
|
|
cat >> "$conf_file" <<CONF
|
|
forward-zone:
|
|
name: "."
|
|
forward-addr: 1.1.1.1
|
|
forward-addr: 1.0.0.1
|
|
CONF
|
|
fi
|
|
;;
|
|
google)
|
|
if [[ "$USE_DOT" == "true" ]]; then
|
|
cat >> "$conf_file" <<CONF
|
|
forward-zone:
|
|
name: "."
|
|
forward-tls-upstream: yes
|
|
forward-addr: 8.8.8.8@853#dns.google
|
|
forward-addr: 8.8.4.4@853#dns.google
|
|
CONF
|
|
else
|
|
cat >> "$conf_file" <<CONF
|
|
forward-zone:
|
|
name: "."
|
|
forward-addr: 8.8.8.8
|
|
forward-addr: 8.8.4.4
|
|
CONF
|
|
fi
|
|
;;
|
|
quad9)
|
|
if [[ "$USE_DOT" == "true" ]]; then
|
|
cat >> "$conf_file" <<CONF
|
|
forward-zone:
|
|
name: "."
|
|
forward-tls-upstream: yes
|
|
forward-addr: 9.9.9.9@853#dns.quad9.net
|
|
forward-addr: 149.112.112.112@853#dns.quad9.net
|
|
CONF
|
|
else
|
|
cat >> "$conf_file" <<CONF
|
|
forward-zone:
|
|
name: "."
|
|
forward-addr: 9.9.9.9
|
|
forward-addr: 149.112.112.112
|
|
CONF
|
|
fi
|
|
;;
|
|
*)
|
|
# Custom IP address
|
|
cat >> "$conf_file" <<CONF
|
|
forward-zone:
|
|
name: "."
|
|
forward-tls-upstream: $dot_flag
|
|
forward-addr: $FORWARDER
|
|
CONF
|
|
;;
|
|
esac
|
|
|
|
print_success "Forwarding config written (upstream: $FORWARDER, DoT: $USE_DOT)"
|
|
}
|
|
|
|
# ============================================================================
|
|
# LOCAL ZONES CONFIGURATION
|
|
# ============================================================================
|
|
|
|
generate_local_zones_config() {
|
|
local conf_file="$UNBOUND_CONF_DIR/local-zones.conf"
|
|
|
|
cat > "$conf_file" <<CONF
|
|
# Local zones — generated by $SCRIPT_NAME
|
|
server:
|
|
CONF
|
|
|
|
for zone in "${LOCAL_ZONES[@]}"; do
|
|
echo " local-zone: \"${zone}.\" static" >> "$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
|
|
# NSD stub zones — generated by $SCRIPT_NAME
|
|
# Forwards these zones to local NSD on port $NSD_PORT
|
|
CONF
|
|
|
|
for zone in "${NSD_STUB_ZONES[@]}"; do
|
|
cat >> "$conf_file" <<CONF
|
|
|
|
stub-zone:
|
|
name: "$zone"
|
|
stub-addr: 127.0.0.1@$NSD_PORT
|
|
CONF
|
|
done
|
|
|
|
print_success "NSD stub zones written (${#NSD_STUB_ZONES[@]} zones → port $NSD_PORT)"
|
|
}
|
|
|
|
# ============================================================================
|
|
# FIREWALL CONFIGURATION
|
|
# ============================================================================
|
|
|
|
configure_firewall() {
|
|
if [[ "$LISTEN_ALL" != "true" && "$LISTEN_IP" == "127.0.0.1" ]]; then
|
|
print_info "Listening on localhost only — skipping firewall"
|
|
return
|
|
fi
|
|
|
|
print_info "Configuring firewall"
|
|
|
|
if [[ "$DRY_RUN" == "true" ]]; then
|
|
print_info "[DRY RUN] Would open port 53/tcp and 53/udp"
|
|
return
|
|
fi
|
|
|
|
if command -v ufw &>/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 "$@"
|