From 2374e4e81c05aaa59c2e8ffd0b0f74f63e5dbcea Mon Sep 17 00:00:00 2001 From: Stanislas Date: Thu, 11 Dec 2025 13:14:56 +0100 Subject: [PATCH] Refactor Unbound setup and add E2E tests (#1340) Refactor Unbound DNS installation to use modern `conf.d` pattern and add E2E testing. **Changes:** - Unified Unbound config across all distros using `/etc/unbound/unbound.conf.d/openvpn.conf` - Added startup validation with retry logic - Added `ip-freebind` to allow binding before tun interface exists - E2E tests now verify Unbound DNS resolution from VPN clients **Testing:** - Server: verifies config creation, interface binding, security options - Client: verifies DNS resolution through Unbound (10.8.0.1) --- Closes https://github.com/angristan/openvpn-install/issues/602 Closes https://github.com/angristan/openvpn-install/pull/604 Closes https://github.com/angristan/openvpn-install/issues/1189 Co-authored-by: Henry N --- openvpn-install.sh | 178 +++++++++++++++----------------------- test/Dockerfile.client | 2 + test/Dockerfile.server | 9 +- test/client-entrypoint.sh | 22 +++++ test/server-entrypoint.sh | 81 ++++++++++++++++- 5 files changed, 180 insertions(+), 112 deletions(-) diff --git a/openvpn-install.sh b/openvpn-install.sh index 28e8040..31087c1 100755 --- a/openvpn-install.sh +++ b/openvpn-install.sh @@ -351,120 +351,84 @@ function installOpenVPNRepo() { function installUnbound() { log_info "Installing Unbound DNS resolver..." - # If Unbound isn't installed, install it - if [[ ! -e /etc/unbound/unbound.conf ]]; then + # Install Unbound if not present + if [[ ! -e /etc/unbound/unbound.conf ]]; then if [[ $OS =~ (debian|ubuntu) ]]; then run_cmd "Installing Unbound" apt-get install -y unbound - - # Configuration - echo 'interface: 10.8.0.1 -access-control: 10.8.0.1/24 allow -hide-identity: yes -hide-version: yes -use-caps-for-id: yes -prefetch: yes' >>/etc/unbound/unbound.conf - elif [[ $OS =~ (centos|oracle) ]]; then run_cmd "Installing Unbound" yum install -y unbound - - # Configuration - sed -i 's|# interface: 0.0.0.0$|interface: 10.8.0.1|' /etc/unbound/unbound.conf - sed -i 's|# access-control: 127.0.0.0/8 allow|access-control: 10.8.0.1/24 allow|' /etc/unbound/unbound.conf - sed -i 's|# hide-identity: no|hide-identity: yes|' /etc/unbound/unbound.conf - sed -i 's|# hide-version: no|hide-version: yes|' /etc/unbound/unbound.conf - sed -i 's|use-caps-for-id: no|use-caps-for-id: yes|' /etc/unbound/unbound.conf - elif [[ $OS =~ (fedora|amzn2023) ]]; then run_cmd "Installing Unbound" dnf install -y unbound - - # Configuration - sed -i 's|# interface: 0.0.0.0$|interface: 10.8.0.1|' /etc/unbound/unbound.conf - sed -i 's|# access-control: 127.0.0.0/8 allow|access-control: 10.8.0.1/24 allow|' /etc/unbound/unbound.conf - sed -i 's|# hide-identity: no|hide-identity: yes|' /etc/unbound/unbound.conf - sed -i 's|# hide-version: no|hide-version: yes|' /etc/unbound/unbound.conf - sed -i 's|# use-caps-for-id: no|use-caps-for-id: yes|' /etc/unbound/unbound.conf - elif [[ $OS == "arch" ]]; then run_cmd "Installing Unbound" pacman -Syu --noconfirm unbound - - # Get root servers list - run_cmd "Downloading root hints" curl -o /etc/unbound/root.hints https://www.internic.net/domain/named.cache - # Verify download was successful and file contains expected content - if [[ ! -s /etc/unbound/root.hints ]] || ! grep -q "ROOT-SERVERS" /etc/unbound/root.hints; then - run_cmd "Cleaning up invalid file" rm -f /etc/unbound/root.hints - log_fatal "Failed to download root.hints or file is invalid!" - fi - - if [[ ! -f /etc/unbound/unbound.conf.old ]]; then - run_cmd "Backing up unbound.conf" mv /etc/unbound/unbound.conf /etc/unbound/unbound.conf.old - fi - - echo 'server: - use-syslog: yes - do-daemonize: no - username: "unbound" - directory: "/etc/unbound" - trust-anchor-file: trusted-key.key - root-hints: root.hints - interface: 10.8.0.1 - access-control: 10.8.0.1/24 allow - port: 53 - num-threads: 2 - use-caps-for-id: yes - harden-glue: yes - hide-identity: yes - hide-version: yes - qname-minimisation: yes - prefetch: yes' >/etc/unbound/unbound.conf - fi - - # IPv6 DNS for all OS - if [[ $IPV6_SUPPORT == 'y' ]]; then - echo 'interface: fd42:42:42:42::1 -access-control: fd42:42:42:42::/112 allow' >>/etc/unbound/unbound.conf - fi - - if [[ ! $OS =~ (fedora|centos|oracle|amzn2023) ]]; then - # DNS Rebinding fix - echo "private-address: 10.0.0.0/8 -private-address: fd42:42:42:42::/112 -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 -private-address: 127.0.0.0/8 -private-address: ::ffff:0:0/96" >>/etc/unbound/unbound.conf - fi - else # Unbound is already installed - echo 'include: /etc/unbound/openvpn.conf' >>/etc/unbound/unbound.conf - - # Add Unbound 'server' for the OpenVPN subnet - echo 'server: -interface: 10.8.0.1 -access-control: 10.8.0.1/24 allow -hide-identity: yes -hide-version: yes -use-caps-for-id: yes -prefetch: yes -private-address: 10.0.0.0/8 -private-address: fd42:42:42:42::/112 -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 -private-address: 127.0.0.0/8 -private-address: ::ffff:0:0/96' >/etc/unbound/openvpn.conf - if [[ $IPV6_SUPPORT == 'y' ]]; then - echo 'interface: fd42:42:42:42::1 -access-control: fd42:42:42:42::/112 allow' >>/etc/unbound/openvpn.conf fi fi + # Configure Unbound for OpenVPN (runs whether freshly installed or pre-existing) + # Create conf.d directory (works on all distros) + run_cmd "Creating Unbound config directory" mkdir -p /etc/unbound/unbound.conf.d + + # Ensure main config includes conf.d directory + # Modern Debian/Ubuntu use include-toplevel, others need include directive + if ! grep -qE "include(-toplevel)?:\s*.*/etc/unbound/unbound.conf.d" /etc/unbound/unbound.conf 2>/dev/null; then + # Add include directive for conf.d if not present + echo 'include: "/etc/unbound/unbound.conf.d/*.conf"' >>/etc/unbound/unbound.conf + fi + + # Generate OpenVPN-specific Unbound configuration + # Using consistent best-practice settings across all distros + { + echo 'server:' + echo ' # OpenVPN DNS resolver configuration' + echo ' interface: 10.8.0.1' + echo ' access-control: 10.8.0.0/24 allow' + echo '' + echo ' # Security hardening' + echo ' hide-identity: yes' + echo ' hide-version: yes' + echo ' harden-glue: yes' + echo ' harden-dnssec-stripped: yes' + echo '' + echo ' # Performance optimizations' + echo ' prefetch: yes' + echo ' use-caps-for-id: yes' + echo ' qname-minimisation: yes' + echo '' + echo ' # Allow binding before tun interface exists' + echo ' ip-freebind: yes' + echo '' + echo ' # DNS rebinding protection' + echo ' private-address: 10.0.0.0/8' + echo ' private-address: 172.16.0.0/12' + echo ' private-address: 192.168.0.0/16' + echo ' private-address: 169.254.0.0/16' + echo ' private-address: 127.0.0.0/8' + echo ' private-address: fd00::/8' + echo ' private-address: fe80::/10' + echo ' private-address: ::ffff:0:0/96' + + # IPv6 support + if [[ $IPV6_SUPPORT == 'y' ]]; then + echo '' + echo ' # IPv6 VPN support' + echo ' interface: fd42:42:42:42::1' + echo ' access-control: fd42:42:42:42::/112 allow' + echo ' private-address: fd42:42:42:42::/112' + fi + } >/etc/unbound/unbound.conf.d/openvpn.conf + run_cmd "Enabling Unbound service" systemctl enable unbound run_cmd "Starting Unbound service" systemctl restart unbound + + # Validate Unbound is running + for i in {1..10}; do + if pgrep -x unbound >/dev/null; then + return 0 + fi + sleep 1 + done + log_fatal "Unbound failed to start. Check 'journalctl -u unbound' for details." } function resolvePublicIP() { @@ -1775,9 +1739,13 @@ function renewMenu() { } function removeUnbound() { - # Remove OpenVPN-related config - run_cmd "Removing Unbound include" sed -i '/include: \/etc\/unbound\/openvpn.conf/d' /etc/unbound/unbound.conf - run_cmd "Removing OpenVPN Unbound config" rm /etc/unbound/openvpn.conf + run_cmd "Removing OpenVPN Unbound config" rm -f /etc/unbound/unbound.conf.d/openvpn.conf + + # Clean up include directive if conf.d directory is now empty + if [[ -d /etc/unbound/unbound.conf.d ]] && [[ -z "$(ls -A /etc/unbound/unbound.conf.d)" ]]; then + run_cmd "Cleaning up Unbound include directive" \ + sed -i '/^include: "\/etc\/unbound\/unbound\.conf\.d\/\*\.conf"$/d' /etc/unbound/unbound.conf + fi until [[ $REMOVE_UNBOUND =~ (y|n) ]]; do log_info "If you were already using Unbound before installing OpenVPN, I removed the configuration related to OpenVPN." @@ -1786,7 +1754,6 @@ function removeUnbound() { if [[ $REMOVE_UNBOUND == 'y' ]]; then log_info "Removing Unbound..." - # Stop Unbound run_cmd "Stopping Unbound" systemctl stop unbound if [[ $OS =~ (debian|ubuntu) ]]; then @@ -1800,7 +1767,6 @@ function removeUnbound() { fi run_cmd "Removing Unbound config" rm -rf /etc/unbound/ - log_success "Unbound removed!" else run_cmd "Restarting Unbound" systemctl restart unbound @@ -1887,7 +1853,7 @@ function removeOpenVPN() { run_cmd "Removing OpenVPN logs" rm -rf /var/log/openvpn # Unbound - if [[ -e /etc/unbound/openvpn.conf ]]; then + if [[ -e /etc/unbound/unbound.conf.d/openvpn.conf ]]; then removeUnbound fi log_success "OpenVPN removed!" diff --git a/test/Dockerfile.client b/test/Dockerfile.client index 6ce945a..63b0cc6 100644 --- a/test/Dockerfile.client +++ b/test/Dockerfile.client @@ -5,11 +5,13 @@ FROM ubuntu:24.04 ENV DEBIAN_FRONTEND=noninteractive # Install OpenVPN client and testing tools +# dnsutils provides dig for DNS testing with Unbound RUN apt-get update && apt-get install -y --no-install-recommends \ openvpn \ iproute2 \ iputils-ping \ procps \ + dnsutils \ && rm -rf /var/lib/apt/lists/* # Create TUN device directory (device will be mounted at runtime) diff --git a/test/Dockerfile.server b/test/Dockerfile.server index 2cee391..fee1c81 100644 --- a/test/Dockerfile.server +++ b/test/Dockerfile.server @@ -8,21 +8,22 @@ ARG BASE_IMAGE ENV DEBIAN_FRONTEND=noninteractive # Install basic dependencies based on the OS +# dnsutils/bind-utils provides dig for DNS testing with Unbound RUN if command -v apt-get >/dev/null; then \ apt-get update && apt-get install -y --no-install-recommends \ - iproute2 iptables curl procps systemd systemd-sysv \ + iproute2 iptables curl procps systemd systemd-sysv dnsutils \ && rm -rf /var/lib/apt/lists/*; \ elif command -v dnf >/dev/null; then \ dnf install -y --allowerasing \ - iproute iptables curl procps-ng systemd tar gzip \ + iproute iptables curl procps-ng systemd tar gzip bind-utils \ && dnf clean all; \ elif command -v yum >/dev/null; then \ yum install -y \ - iproute iptables curl procps-ng systemd tar gzip \ + iproute iptables curl procps-ng systemd tar gzip bind-utils \ && yum clean all; \ elif command -v pacman >/dev/null; then \ pacman -Syu --noconfirm \ - iproute2 iptables curl procps-ng \ + iproute2 iptables curl procps-ng bind \ && pacman -Scc --noconfirm; \ fi diff --git a/test/client-entrypoint.sh b/test/client-entrypoint.sh index c2ed605..69b779b 100755 --- a/test/client-entrypoint.sh +++ b/test/client-entrypoint.sh @@ -81,6 +81,28 @@ else exit 1 fi +# Test 3: DNS resolution through Unbound +echo "Test 3: Testing DNS resolution via Unbound (10.8.0.1)..." +DNS_SUCCESS=false +for i in 1 2 3 4 5; do + DIG_OUTPUT=$(dig @10.8.0.1 example.com +short +time=5 2>&1) + if [ -n "$DIG_OUTPUT" ] && ! echo "$DIG_OUTPUT" | grep -qi "timed out\|SERVFAIL\|connection refused"; then + DNS_SUCCESS=true + break + fi + echo "DNS attempt $i failed:" + echo "$DIG_OUTPUT" + sleep 2 +done +if [ "$DNS_SUCCESS" = true ]; then + echo "PASS: DNS resolution through Unbound works" + echo "Resolved example.com to: $(dig @10.8.0.1 example.com +short +time=5)" +else + echo "FAIL: DNS resolution through Unbound failed after 5 attempts" + dig @10.8.0.1 example.com +time=5 || true + exit 1 +fi + echo "" echo "==========================================" echo " ALL TESTS PASSED!" diff --git a/test/server-entrypoint.sh b/test/server-entrypoint.sh index 2f3c38b..527c258 100755 --- a/test/server-entrypoint.sh +++ b/test/server-entrypoint.sh @@ -20,7 +20,7 @@ export APPROVE_IP=y export IPV6_SUPPORT=n export PORT_CHOICE=1 export PROTOCOL_CHOICE=1 -export DNS=9 # Google DNS (works in containers) +export DNS=2 # Self-hosted Unbound DNS resolver export COMPRESSION_ENABLED=n export CUSTOMIZE_ENC=n export CLIENT=testclient @@ -29,8 +29,11 @@ export ENDPOINT=openvpn-server # Prepare script for container environment: # - Replace systemctl calls with no-ops (systemd doesn't work in containers) +# - Skip Unbound startup validation (we start Unbound manually later) # This ensures the script won't fail silently on systemctl commands -sed 's/\bsystemctl /echo "[SKIPPED] systemctl " # /g' /opt/openvpn-install.sh >/tmp/openvpn-install.sh +sed -e 's/\bsystemctl /echo "[SKIPPED] systemctl " # /g' \ + -e 's/log_fatal "Unbound failed to start/return 0 # [SKIPPED] /g' \ + /opt/openvpn-install.sh >/tmp/openvpn-install.sh chmod +x /tmp/openvpn-install.sh echo "Running OpenVPN install script..." @@ -243,6 +246,80 @@ echo "" echo "=== All Certificate Renewal Tests PASSED ===" echo "" +# ===================================================== +# Start and verify Unbound DNS resolver +# ===================================================== +echo "=== Starting Unbound DNS Resolver ===" + +# Start Unbound manually (systemctl commands are no-ops in container) +if [ -f /etc/unbound/unbound.conf ]; then + echo "Starting Unbound DNS resolver..." + + # Create root key for DNSSEC if it doesn't exist + # Normally, unbound.service's ExecStartPre copies /usr/share/dns/root.key to /var/lib/unbound/root.key + # In Docker, policy-rc.d blocks service starts during apt install, so this never happens + if [ ! -f /var/lib/unbound/root.key ] && [ -f /usr/share/dns/root.key ]; then + mkdir -p /var/lib/unbound + cp /usr/share/dns/root.key /var/lib/unbound/root.key + chown -R unbound:unbound /var/lib/unbound 2>/dev/null || true + fi + + unbound + # Poll up to 10 seconds for Unbound to start + for _ in $(seq 1 10); do + if pgrep -x unbound >/dev/null; then + echo "PASS: Unbound is running" + break + fi + sleep 1 + done + if ! pgrep -x unbound >/dev/null; then + echo "FAIL: Unbound failed to start" + # Show debug info + unbound-checkconf /etc/unbound/unbound.conf 2>&1 || true + exit 1 + fi +else + echo "FAIL: /etc/unbound/unbound.conf not found" + exit 1 +fi + +echo "" +echo "=== Verifying Unbound Installation ===" + +# Verify Unbound config exists in conf.d directory +UNBOUND_OPENVPN_CONF="/etc/unbound/unbound.conf.d/openvpn.conf" +if [ -f "$UNBOUND_OPENVPN_CONF" ]; then + echo "PASS: Found Unbound config at $UNBOUND_OPENVPN_CONF" +else + echo "FAIL: OpenVPN Unbound config not found at $UNBOUND_OPENVPN_CONF" + echo "Contents of /etc/unbound/:" + ls -la /etc/unbound/ + ls -la /etc/unbound/unbound.conf.d/ 2>/dev/null || true + exit 1 +fi + +# Verify Unbound listens on VPN gateway +if grep -q "interface: 10.8.0.1" "$UNBOUND_OPENVPN_CONF"; then + echo "PASS: Unbound configured to listen on 10.8.0.1" +else + echo "FAIL: Unbound not configured for 10.8.0.1" + cat "$UNBOUND_OPENVPN_CONF" + exit 1 +fi + +# Verify OpenVPN pushes correct DNS +if grep -q 'push "dhcp-option DNS 10.8.0.1"' /etc/openvpn/server.conf; then + echo "PASS: OpenVPN configured to push Unbound DNS" +else + echo "FAIL: OpenVPN not configured to push Unbound DNS" + grep "dhcp-option DNS" /etc/openvpn/server.conf || echo "No DNS push found" + exit 1 +fi + +echo "=== Unbound Installation Verified ===" +echo "" + # Start OpenVPN server manually (systemd doesn't work in containers) echo "Starting OpenVPN server..."