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 <henrynmail-github@yahoo.de>
This commit is contained in:
Stanislas
2025-12-11 13:14:56 +01:00
committed by GitHub
parent 1aae852c60
commit 2374e4e81c
5 changed files with 180 additions and 112 deletions

View File

@@ -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!"

View File

@@ -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)

View File

@@ -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

View File

@@ -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!"

View File

@@ -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..."