#dns #openvpn #devops #linux
Content Filtering + (DoH) for OpenVPN Clients via BIND-DNSCrypt

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

This tutorial was written during a period of deep sadness following the 7.9 Turkey earthquake in 6 February 2023.

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
In this tutorial, we won't go over how to set up BIND, OpenVPN or DNSCrypt from scratch. We assume that if you are reading here — at the level of DoH + content filtering — you already have the technical knowledge to install these packages via your package manager.

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.

At the end of this tutorial you will be able to watch your OpenVPN clients' HTTP traffic. There is a bash script at the end for that.

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 plain cloudflare (1.1.1.1)
  • Enable logging so we can test the setup is working
We specially use 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?

If you forward all DNS traffic to DNSCrypt, the proxy adds latency — not too bad, but it can affect local resolve operations. Consider a very busy mail server querying many DNSBLs, or nginx using your primary DNS as a resolver. All of that traffic would be forwarded through the proxy. For better performance, consider forwarding only OpenVPN client 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.

Because that is crazy — proxying over and over. We already have DNSCrypt as a DNS proxy. There is no need to add an extra layer here. systemd-resolved also needs considerable extra work to function well with DNSSEC-configured BIND. For this setup, consider disabling systemd-resolved entirely:
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
If you chose Option B (forward only OpenVPN traffic), test your local DNS resolution via 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
The --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

Leave a Reply

Your email address will not be published. Required fields are marked *

Post comment