"It's always DNS." — Approx. 3 minutes from now, you will have content-filtered OpenVPN clients running encrypted DNS over HTTPS. This tutorial covers the full stack: BIND as authoritative resolver, DNSCrypt as DoH proxy, Cloudflare Family as upstream, and OpenVPN as the delivery mechanism.
Although the subject of this article is not DNS itself, it cannot be done without briefly mentioning it. Many in the network security field may be familiar with the phrase: "It's always DNS." This is a popular meme within the industry, often referencing the internal domain name system (DNS) — whenever there is a network issue, it's always an issue with DNS.
Despite its simple definition and mission, the complexity it creates in the background makes it a nightmare for humanity. DNS is part of life. It touches everyone's life, from a simple internet user to an experienced system architect. Long story short — DNS is always a hard, challenging topic. DNS is the DNA of system architecture.
What We Have
- BIND as authoritative primary DNS server
- DNSCrypt (dnscrypt-proxy) as DNS proxy and traffic encryption protocol
- Cloudflare (DoH) as content-filtered + DNS over HTTPS upstream DNS
- OpenVPN as the secure connection protocol
What We Do
- Integrate dnscrypt-proxy with BIND
- Deal with systemd-resolved conflicts
- Push content-filtered DoH-secured DNS to all OpenVPN clients
- Test the full configuration end-to-end
- Optionally spy on OpenVPN client HTTP traffic via bash script
Here, BIND is used as the authoritative primary DNS server that listens on the public IP for the zones it is responsible for. To make our DNS traffic encrypted and content-filtered, we use cloudflare-family (DoH) via dnscrypt-proxy. We first integrate dnscrypt-proxy with BIND, then address the overall DNS system architecture — including the systemd-resolved complications. Finally, we push the content-filtered DoH-secured DNS to OpenVPN clients, test the configuration, and verify it works.
DNSCrypt Setup
The DNSCrypt project provides the proxy as source code and pre-built binaries for most operating systems and architectures.
By default, DNSCrypt listens on 127.0.0.1. The issue here is that our authoritative DNS server BIND already sits on 127.0.0.1. As mentioned, our BIND setup acts as a recursive DNS server for our local network. If you use default Unbound, you will likely need to make these changes anyway.
- We will assign a different IP for DNSCrypt to listen on —
127.0.2.1:53 - DNSCrypt can cache DNS queries — depending on your overall system setup, you can enable or disable it. We prefer caching at the proxy layer, so we enable it
- We use
cloudflare-family(1.1.1.3) specifically for DNS content filtering for OpenVPN clients. If you don't want content filtering, use plaincloudflare(1.1.1.1) - Enable logging so we can test the setup is working
cloudflare-family 1.1.1.3 here for DNS content filtering for OpenVPN clients. After the test phase is complete, you can switch to any DoH public DNS server from the public server list to get better latency.
Open the configuration file and set the proxy to listen on 127.0.2.1:53, then modify the other options as shown below.
dnscrypt-proxy.toml
server_names = ['cloudflare-family', 'cloudflare-family-ipv6']
listen_addresses = ['127.0.2.1:53']
cache = true
[query_log]
file = '/var/log/dnscrypt-proxy/query.log'
ignored_qtypes = ['DNSKEY', 'NS']
format = 'tsv'
Depending on your configuration, restart the service to apply the changes.
BIND Setup
Here we have two tasks to complete for BIND/OpenVPN integration. But first we need to decide which traffic we will forward to DNSCrypt: our whole DNS traffic, or only OpenVPN client DNS traffic?
The two key BIND changes regardless of your choice:
- Forward DNS traffic to DNSCrypt:
forwarders { 127.0.2.1; }; - Listen on the OpenVPN TUN/TAP interface IP:
listen-on { 127.0.0.1; x.x.x.x; 10.8.0.1; };
Enable BIND logging channels for testing purposes. Then tell BIND to forward to DNSCrypt — edit the named.conf file and forward traffic to 127.0.2.1 where our dnscrypt-proxy is listening.
Tell BIND to listen on our OpenVPN TUN/TAP interface IP 10.8.0.1. This is the key and trickiest part of this tutorial — it is necessary for OpenVPN integration. You can find your TUN/TAP interface IP via ifconfig or directly from your OpenVPN server.conf file.
Option A — Forward All DNS Traffic
Here is the necessary BIND config if you decide to forward all DNS traffic to DNSCrypt.
named.conf
// We defined OpenVPN clients IP range here
acl "vpn" {
10.8.0.0/24;
};
// TUN/TAP interface IP 10.8.0.0/24 is also
// trusted via 'localnets'.
// If you remove 'localnets' you need to
// enable recursion for ACL 'vpn' in options.
acl "trusted" {
localhost;
localnets;
};
options {
// Here we added our TUN/TAP OpenVPN interface IP '10.8.0.1'
// So BIND will listen this address.
// We will push '10.8.0.1' to OpenVPN clients via OpenVPN server
// x.x.x.x is our Public IP
listen-on { 127.0.0.1; x.x.x.x; 10.8.0.1; };
// BIND acts as a authoritative name server
// It will answer all queries for zones it is responsible for
allow-query {
any;
};
// BIND is recursive for only our local
// If you set 'any' you will be open resolver!
allow-recursion {
trusted;
// If you removed 'localnets' from ACL 'trusted'
// uncomment 'vpn' here. Even we forward query still we need recursion.
// vpn;
};
// Zone transfer is disabled
allow-update {
none;
};
// dynamic updates for master zones is disabled
allow-update {
none;
};
// Here we forward all DNS traffic to dnscrypt-proxy
// That means /etc/resolv.conf file is useless now.
// BIND will acts a forwarder only.
// You need to set upstream DNS via dnscrypt-proxy.toml
forwarders {
// dnscrypt-proxy listens on
127.0.2.1;
};
forward only;
};
// Enable queries logging channel
logging {
channel queries_log {
file "/var/log/named/queries.log" versions 1 size 20m;
print-time yes;
print-category yes;
print-severity yes;
severity info;
};
category queries { queries_log; };
};
Option B — Forward Only OpenVPN Client Traffic
If you decided to forward only OpenVPN traffic, we use BIND views to separate the traffic. Key points are highlighted in the comments.
named.conf
// We defined OpenVPN clients IP range for dnscrypt view
acl "vpn" {
10.8.0.0/24;
};
// TUN/TAP interface IP 10.8.0.0/24 is also
// trusted via 'localnets'.
// If you remove 'localnets' you need to
// enable recursion for ACL 'vpn' in view 'dnscrypt'
acl "trusted" {
localhost;
localnets;
};
// The global options defined here is applied to
// all views. (if you don't override
// them in view segment manually)
options {
listen-on { 127.0.0.1; x.x.x.x; 10.8.0.1; };
allow-query {
any;
};
allow-recursion {
trusted;
};
allow-transfer {
none;
};
allow-update {
none;
};
};
logging {
channel queries_log {
file "/var/log/named/queries.log" versions 1 size 20m;
print-time yes;
print-category yes;
print-severity yes;
severity info;
};
category queries { queries_log; };
};
// We are separating OpenVPN client's queries
// and forwarding them to dnscrypt-proxy here
// This must be our FIRST view!
view dnscrypt {
match-clients { vpn; };
// If you removed 'localnets' from ACL 'trusted'
// uncomment 'allow-recursion' here.
// Even we forward query still we need recursion.
// allow-recursion { vpn; };
forwarders {
// dnscrypt-proxy listens on
127.0.2.1;
};
forward only;
};
// All queries excluded OpenVPN clients
// will resolve by our primary DNS server recursively
// Move all your zones under to this view!
// This must be our SECOND view!
view all {
match-clients { any; };
// example zones
zone "." {
type hint;
file "/var/bind/named.cache";
};
zone "127.in-addr.arpa" {
type master;
file "/var/bind/db.127";
};
zone "255.in-addr.arpa" {
type master;
file "/var/bind/db.255";
};
};
rndc reload
System Setup
Now take a deep breath here. We need to check our in-use DNS architecture. Most Linux distributions have started using systemd-resolved.service. In short, this is a frontend resolver that takes over and handles all resolve operations, forwarding them to an upstream DNS. It has many capabilities like caching, DNSSEC validation, and DoT. Let's focus on what matters for our setup.
First, follow the symlink for /etc/resolv.conf to understand your current DNS architecture:
ls -al /etc/resolv.conf
Symlinked to resolv.conf
lrwxrwxrwx 1 root root 32 Dec 9 2018 /etc/resolv.conf -> /run/systemd/resolve/resolv.conf
If symlinked to resolv.conf, applications will directly make DNS requests to the "real" (upstream) DNS resolvers configured in /etc/systemd/resolved.conf. In this case, systemd-resolved only acts as a "resolv.conf manager" — not as a DNS resolver itself. This is what we prefer in our setup.
Symlinked to stub-resolv.conf
lrwxrwxrwx 1 root root 32 Dec 9 2018 /etc/resolv.conf -> /run/systemd/resolve/stub-resolv.conf
If symlinked to stub-resolv.conf, applications will make DNS requests to the DNS stub resolver provided by systemd on address 127.0.0.53. This stub resolver will proxy DNS requests to the upstream DNS resolvers configured in systemd-resolved — applying its own caching and logic. We do not prefer this in our setup.
systemctl stop systemd-resolved.service
systemctl disable systemd-resolved.service
systemctl mask systemd-resolved.service
If you choose to stay behind systemd-resolved, at least make sure it is not in stub mode.
OpenVPN Setup
We are ready to push our secured DNS to OpenVPN clients. Add this line to your OpenVPN server config:
push "dhcp-option DNS 10.8.0.1"
We won't go over how to set up an OpenVPN server from scratch. Instead we are highlighting the key integration steps. If you want to spy on your OpenVPN clients' HTTP traffic, you need an extra step: assign a static IP to each client. That means creating a new certificate for each client and setting the ccd path in server.conf. Many tutorials cover assigning static IPs to OpenVPN clients.
client-config-dir /etc/openvpn/server/ccd/
Here is the full OpenVPN server config file. Key points are highlighted. You do not need to edit any OpenVPN client .ovpn files — we push DNS to all clients via the server.
server.conf
port 1194
push "explicit-exit-notify 1"
proto udp4
dev tun0
ca ca.crt
cert server.crt
key server.key
dh dh.pem
tls-crypt tls-crypt.key
client-config-dir /etc/openvpn/server/ccd/
auth SHA256
auth-nocache
tls-ciphersuites TLS_CHACHA20_POLY1305_SHA256:TLS_AES_128_GCM_SHA256
tls-cipher TLS-ECDHE-ECDSA-WITH-CHACHA20-POLY1305-SHA256:TLS-ECDHE-RSA-WITH-CHACHA20-POLY1305-SHA256:TLS-ECDHE-ECDSA-WITH-AES-128-GCM-SHA256:TLS-ECDHE-RSA-WITH-AES-128-GCM-SHA256
cipher AES-256-GCM
tls-version-min 1.2
remote-cert-tls client
topology subnet
server 10.8.0.0 255.255.255.0
push "redirect-gateway def1 bypass-dhcp"
push "dhcp-option DNS 10.8.0.1"
persist-key
persist-tun
keepalive 10 120
fast-io
reneg-sec 0
client-to-client
resolv-retry infinite
user nobody
group nobody
status /var/log/openvpn-status.log
log /var/log/openvpn.log
verb 4
explicit-exit-notify 1
Depending on your configuration, restart the service to apply the changes.
Testing the Setup
If all went well, we are ready to test. Our testing methodology depends on log analysis and Cloudflare's service check page.
| Test Method | Target | What to Look For |
|---|---|---|
| Cloudflare service check | https://1.1.1.1/help |
"Using DNS over HTTPS (DoH): YES" |
| DNSCrypt log analysis | /var/log/dnscrypt-proxy/query.log |
Queries resolving via cloudflare-family |
| BIND log analysis | /var/log/named/queries.log |
OpenVPN client IPs tagged as view dnscrypt |
| tcpdump traffic analysis | loopback + eth0 | Traffic flowing BIND → DNSCrypt → Cloudflare on port 443 |
Before starting the tests, connect to the OpenVPN server via your client.ovpn file.
Test 1 — Cloudflare DoH Check
Navigate to https://1.1.1.1/help and check the results. If you see "Using DNS over HTTPS (DoH): YES" — congratulations, your setup is working. Also verify that adult content filtering is active.
Test 2 — DNSCrypt Log Verification
Navigate to https://www.wikipedia.org/ and watch the DNSCrypt log. If you see cloudflare-family, BIND has successfully forwarded OpenVPN client DNS queries to upstream DNS (Cloudflare) via the DNSCrypt proxy. Repeating the action will show no new log entry because DNSCrypt has already cached that query.
tail -f /var/log/dnscrypt-proxy/query.log
[2023-02-17 01:01:06] 127.0.0.1 www.wikipedia.org A PASS 56ms cloudflare-family
[2023-02-17 01:01:06] 127.0.0.1 wikipedia.org DS PASS 18ms cloudflare-family
[2023-02-17 01:01:06] 127.0.0.1 dyna.wikimedia.org A PASS 19ms cloudflare-family
[2023-02-17 01:01:06] 127.0.0.1 wikimedia.org DS PASS 16ms cloudflare-family
[2023-02-17 01:01:06] 127.0.0.1 upload.wikimedia.org A PASS 37ms cloudflare-family
[2023-02-17 01:01:06] 127.0.0.1 tr.wikipedia.org A PASS 55ms cloudflare-family
dig. You will see those queries in BIND's queries.log but not in dnscrypt-proxy/query.log — because local traffic is resolved recursively by BIND, not forwarded to DNSCrypt.
Test 3 — BIND View Verification
Navigate to a page and watch the BIND queries log:
tail -f /var/log/named/queries.log
17-Feb-2023 02:16:54.456 queries: info: client @0x7f93547f65e0 10.8.0.25#58096 (en.wikipedia.org): view dnscrypt: query: en.wikipedia.org IN A + (10.8.0.1)
Here 10.8.0.25 is our OpenVPN client's static IP. BIND filtered the source IP of the query via views and tagged it as dnscrypt. 10.8.0.1 is our primary DNS IP that BIND listens on for OpenVPN clients — also our OpenVPN TUN/TAP interface IP. A + means it is a recursive query. After that, BIND will forward this query to 127.0.2.1 (DNSCrypt), which will ultimately reach our upstream DNS — Cloudflare 1.1.1.3 as configured in dnscrypt-proxy.toml.
Test 4 — tcpdump Bidirectional Traffic Analysis
First, check the route of local DNS queries. In this example all DNS traffic is forwarded to DNSCrypt via BIND. Note how even the Postfix mail server's local DNSBL queries are forwarded to 127.0.2.1 — this is the performance degradation mentioned earlier, and why forwarding only OpenVPN client traffic is recommended.
tcpdump -nli lo host 127.0.2.1
05:41:58.506543 IP 127.0.0.1.38222 > 127.0.2.1.53: 9675+% [1au] A? 71.244.107.40.zen.spamhaus.org. (71)
05:41:58.506585 IP 127.0.0.1.2375 > 127.0.2.1.53: 28636+% [1au] A? 71.244.107.40.dnsbl-2.uceprotect.net. (77)
05:41:58.506588 IP 127.0.0.1.11970 > 127.0.2.1.53: 36958+% [1au] A? 71.244.107.40.all.spamrats.com. (71)
05:41:58.506618 IP 127.0.0.1.23052 > 127.0.2.1.53: 47304+% [1au] A? 71.244.107.40.bl.spameatingmonkey.net. (78)
05:41:58.506789 IP 127.0.0.1.39946 > 127.0.2.1.53: 9585+% [1au] A? 71.244.107.40.hostkarma.junkemailfilter.com. (84)
Now check OpenVPN client DNS resolution. Connect via your client.ovpn and browse to a page, then analyze the traffic:
tcpdump -nli lo host 127.0.2.1
05:55:40.907308 IP 127.0.0.1.52381 > 127.0.2.1.53: 1250+% [1au] A? tr.wikipedia.org. (57)
05:55:40.932234 IP 127.0.2.1.53 > 127.0.0.1.52381: 1250 2/0/1 CNAME dyna.wikimedia.org., A 91.198.174.192 (90)
05:55:40.932586 IP 127.0.0.1.32391 > 127.0.2.1.53: 31803+% [1au] DS? wikipedia.org. (54)
05:55:40.939988 IP 127.0.2.1.53 > 127.0.0.1.32391: 31803| 0/0/1 (42)
05:55:40.941353 IP 127.0.0.1.37448 > 127.0.2.1.53: 14139+% [1au] A? dyna.wikimedia.org. (59)
05:55:40.956795 IP 127.0.2.1.53 > 127.0.0.1.37448: 14139 1/0/1 A 91.198.174.192 (63)
Test 5 — Final DNS Traffic (Public Interface)
Analyze the final DNS traffic bidirectionally via tcpdump on the public interface. The final source address of the query is our server's public IP, and the final destination is Cloudflare 1.0.0.1 port 443 — confirming traffic is encrypted via HTTPS.
tcpdump -nli eth0 host 1.0.0.1 and host 159.69.xx.xx
06:18:20.046509 IP 159.69.xx.xx.62280 > 1.0.0.1.443: Flags [.], ack 147344, win 1055, length 0
06:18:22.020358 IP 159.69.xx.xx.62280 > 1.0.0.1.443: Flags [P.], seq 46176:46278, ack 147344, win 1055, length 102
06:18:22.020410 IP 159.69.xx.xx.62280 > 1.0.0.1.443: Flags [P.], seq 46278:46377, ack 147344, win 1055, length 99
06:18:22.025708 IP 1.0.0.1.443 > 159.69.xx.xx.62280: Flags [.], ack 46377, win 8, length 0
06:18:22.026154 IP 1.0.0.1.443 > 159.69.xx.xx.62280: Flags [P.], seq 147344:147379, ack 46377, win 8, length 35
Bonus: spy_vpn.sh — Watch OpenVPN Client HTTP Traffic
A simple bash script for monitoring your OpenVPN clients' HTTP traffic. Modify it freely for your own needs.
spy_vpn.sh
#!/usr/bin/env bash
#
# Copyright (C) 2023 Hasan ÇALIŞIR
# Distributed under the GNU General Public License, version 2.0.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
#
# ---------------------------------------------------------------------
# Written by : (hsntgm) Hasan ÇALIŞIR - hasan.calisir@psauxit.com
# https://www.psauxit.com/
# --------------------------------------------------------------------
#
# The aim of this script is spying OpenVPN client's HTTP traffic.
# - Visit https://www.psauxit.com/secured-openvpn-clients-dnscrypt/
# - blog post for detailed instructions.
# ADJUST USER DEFINED SETTINGS
####################################################
# set your ccd path that holds each client static IP
ccd="/etc/openvpn/server/ccd"
# set your bind queries log path
queries="/var/log/named/queries.log"
# set your openvpn clients IP Pool
# max 255.255.0.0
pool="10.8.0.0"
####################################################
# set color
red=$(tput setaf 1)
cyan=$(tput setaf 6)
magenta=$(tput setaf 5)
yellow=$(tput setaf 3)
TPUT_BOLD=$(tput bold)
TPUT_BGRED=$(tput setab 1)
TPUT_WHITE=$(tput setaf 7)
reset=$(tput sgr 0)
printf -v m_tab '%*s' 2 ''
# fatal
fatal () {
printf >&2 "\n${m_tab}%s ABORTED %s %s \n\n" "${TPUT_BGRED}${TPUT_WHITE}${TPUT_BOLD}" "${reset}" "${@}"
exit 1
}
# discover script path
this_script_full_path="${BASH_SOURCE[0]}"
if command -v dirname >/dev/null 2>&1 && command -v readlink >/dev/null 2>&1 && command -v basename >/dev/null 2>&1; then
# Symlinks
while [[ -h "${this_script_full_path}" ]]; do
this_script_path="$( cd -P "$( dirname "${this_script_full_path}" )" >/dev/null 2>&1 && pwd )"
this_script_full_path="$(readlink "${this_script_full_path}")"
# Resolve
if [[ "${this_script_full_path}" != /* ]] ; then
this_script_full_path="${this_script_path}/${this_script_full_path}"
fi
done
this_script_path="$( cd -P "$( dirname "${this_script_full_path}" )" >/dev/null 2>&1 && pwd )"
this_script_name="$(basename "${this_script_full_path}")"
else
fatal "Cannot find script path! Check you have dirname,readlink,basename tools"
fi
# populate array
# key-value --> client name-static ip
clients_name_ip () {
# declare global associative array
declare -gA clients
printf "\n"
for each in "${ccd}"/*; do
if [[ ! -f ${each} ]]; then
printf "$m_tab\e[33mWarn:\e[0m %s is not a file\n" "${each}" >&2
continue
fi
client=${each##*/}
if ! client_ip=$(awk -v pool="${pool%.*}" '
BEGIN { exit_code = 1 }
/^ifconfig-push\s+([0-9]{1,3}\.){3}[0-9]{1,3}/ {
split($2, a, ".");
if (a[1]<256 && a[2]<256 && a[3]<256 && a[4]<256 && $2 ~ pool) {
print $2;
exit_code = 0
}
}
END { exit exit_code }' "${each}"); then
printf "$m_tab\e[33mWarn:\e[0m failed to extract IP address from %s\n" "${each}" >&2
continue
fi
clients[${client}]="${client_ip}"
done
}
# list OpenVPN clients
list_clients () {
printf '\n%s%s# OpenVPN Clients\n' "$m_tab" "$cyan"
printf '%s --------------------------------%s\n' "$m_tab" "$reset"
while read -r line
do
printf '%s%s%s\n' "$m_tab" "$magenta" "$(printf ' %s' "$line")$reset"
done < <(find "$ccd" -type f -exec basename {} \; | paste - - -)
printf '%s%s --------------------------------%s\n\n' "$cyan" "$m_tab" "$reset"
}
# check openvpn client existence
check_client () {
if [[ -z "${clients["$1"]}" ]]; then
fatal "Cannot find OpenVPN client --> $1! Use --list to show OpenVPN Clients."
fi
}
main () {
local my_file
my_file=$(mktemp)
# search openvpn client static IP (logrotated ones included), parse DNS queries, sort
{ find "${queries%/*}/" -name "*${queries##*/}*" -type f -print0 |
xargs -0 zgrep -i -h -w "${ip}" |
awk 'match($0, /query:[[:space:]]*([^[:space:]]+)/, a) {print $1" "$2" "a[1]}' |
sort -s -k1.8n -k1.4M -k1.1n
} 2>/dev/null > "${my_file}"
# take immediate snapshot of pipestatus
status=( "${PIPESTATUS[@]}" )
# remove the sort command exit status from the PIPESTATUS array
# that not cause any parse error
if [ ${#status[@]} -ge 4 ]; then unset 'status[3]'; fi
# check any piped commands fails
if [[ "${status[*]}" =~ [^0\ ] ]]; then
for i in "${!status[@]}"; do
if [[ "${status[i]}" -ne 0 ]]; then
case $i in
0) { printf "%s\n" "${red}${m_tab}find command failed with exit status ${status[i]}, check the path is correct --> ${queries}${reset}"; return 1; } ;;
# error code 123 for zgrep means no http traffic at all for this client, so this is not a parse error and excluded
1) [[ "${status[i]}" -eq 127 ]] && { printf "%s\n" "${red}${m_tab}zgrep command not found. Install zgrep and try again.${reset}"; return 1; } ;;
2) { printf "%s\n" "${red}${m_tab}awk command failed with exit status ${status[i]}, please open a bug${reset}"; return 1; } ;;
esac
fi
done
fi
# if parse error not found also check 'no HTTP traffic' for the client
# save per openvpn client http traffic to file
if ! [[ -s "${my_file}" ]]; then
echo -ne "${cyan}${m_tab}Openvpn Client --> ${magenta}${client}${reset} "
echo -e "${cyan}--> ${yellow}No HTTP traffic found${reset}"
elif ! rsync -r --delete --remove-source-files "${my_file}" \
"${this_script_path}/http_traffic_${client}" >/dev/null 2>&1; then
trap 'rm -f "${my_file}"' ERR
echo -ne "${red}${m_tab}Error: Failed to save HTTP traffic for "
echo -e "client ${client} to file ${this_script_path}/http_traffic_${client}${reset}"
else
echo -ne "${cyan}${m_tab}Openvpn Client --> ${magenta}${client}${reset} "
echo -e "${cyan}--> HTTP traffic saved in --> ${magenta}${this_script_path}/http_traffic_${client}${reset}"
fi
[[ $1 == single ]] && printf "\n"
}
# parse http traffic for all openvpn clients, this will be run in parallel for every client
# this can cause high cpu usage if you have many openvpn clients and heavy internet traffic
all_clients () {
clients_name_ip
list_clients
num_cores=$(nproc)
# main parsing function
parse_traffic () {
local client ip
client="${1}"
ip="${clients[$client]}"
main
}
# Loop through the clients and parse their HTTP traffic in parallel
# Limit the number of parallel processes to the number of CPU core
for client in "${!clients[@]}"
do
parse_traffic "${client}" &
if (( $(jobs -r -p | wc -l) >= num_cores )); then
wait -n
fi
done
# wait all parallel jobs complete
wait
printf "\n"
}
# parse http traffic for specific openvpn client
single_client () {
local client ip
clients_name_ip
check_client "${1}"
client="${1}"
ip="${clients[${1}]}"
main single
}
# live watch http traffic for specific OpenVPN client
watch_client () {
clients_name_ip
check_client "${1}"
tail -f "${queries}" \
| grep --line-buffered -w "${clients[${1}]}" \
| awk -v space="${m_tab}" '{for(i=1; i<=NF; i++) if($i~/query:/ && $(i+1) !~ /addr\.arpa/) printf "%s\033[35m%s\033[39m \033[36m%s\033[39m\n", space, $1, $(i+1)}'
}
# help
help () {
printf "\n%s\n" "${m_tab}${cyan}# Script Help"
printf "%s\n" "${m_tab}# --------------------------------------------------------------------------------------------------------------------"
printf "%s\n" "${m_tab}#${m_tab} -a | --all-clients get all OpenVPN clients http traffic to separate file e.g ./spy_vpn.sh --all-clients"
printf "%s\n" "${m_tab}#${m_tab} -c | --client get specific OpenVPN client http traffic to file e.g ./spy_vpn.sh --client JohnDoe"
printf "%s\n" "${m_tab}#${m_tab} -l | --list list OpenVPN clients e.g ./spy_vpn.sh --list"
printf "%s\n" "${m_tab}#${m_tab} -w | --watch live watch specific OpenVPN client http traffic ./spy_vpn.sh --watch JohnDoe"
printf "%s\n" "${m_tab}#${m_tab} -h | --help help screen"
printf "%s\n\n" "${m_tab}# ----------------------------------------------------------------------------------------------------------------------${reset}"
}
# invalid script option
inv_opt () {
printf "\n%s\\n" "${red}${m_tab}Invalid option${reset}"
printf "%s\\n\n" "${cyan}${m_tab}Try './${this_script_name} --help' for more information.${reset}"
exit 1
}
# script management
man () {
if [[ "$#" -eq 0 || "$#" -gt 2 ]]; then
printf "\n%s\\n" "${red}${m_tab}Argument required or too many argument${reset}"
printf "%s\\n\n" "${cyan}${m_tab}Try './${this_script_name} --help' for more information.${reset}"
exit 1
fi
# set script arguments
while [[ "$#" -gt 0 ]]; do
case "$1" in
-a | --all-clients ) all_clients ;;
-c | --client ) single_client "$2" ;;
-w | --watch ) watch_client "$2" ;;
-l | --list ) list_clients ;;
-h | --help ) help ;;
* ) inv_opt ;;
esac
break
done
}
# Call man
man "${@}"
The script supports the following arguments:
| Argument | Description | Example |
|---|---|---|
-a / --all-clients |
Get all OpenVPN clients' HTTP traffic to separate files | ./spy_vpn.sh --all-clients |
-c / --client |
Get specific OpenVPN client HTTP traffic to file | ./spy_vpn.sh --client JohnDoe |
-l / --list |
List all OpenVPN clients | ./spy_vpn.sh --list |
-w / --watch |
Live watch specific OpenVPN client HTTP traffic | ./spy_vpn.sh --watch JohnDoe |
-h / --help |
Show help screen | ./spy_vpn.sh --help |
--all-clients mode runs parsing in parallel — one process per CPU core — via bash job control. This can cause high CPU usage if you have many OpenVPN clients with heavy internet traffic.
CHANGELOG
- 24 Feb 2023 — Added more info about BIND Setup + Added tcpdump traffic analysis
- 22 Feb 2023 — Added bash script
spy_vpn.sh - 23 Dec 2023 — Script updated — drop addr.arpa queries from live watch
- 23 Dec 2023 — Lists of public DNSCrypt and DoH servers updated
- 23 Apr 2024 — Fixed some style issues on article
- 25 Mar 2026 — Fixed grammar issues, redesigned article style, and simplified technical details