Skip to main content

DNS Over TLS Using BIND And Nginx

Shenzhen, China

Test Run

Deploying a Simple DoT-DNS Gateway

The NGINX Stream (TCP/UDP) module supports SSL termination, and so it’s actually really simple to set up a DoT service. You can create a simple DoT gateway in just a few lines of NGINX configuration. You need an upstream block for your DNS servers - I am using the default Google DNS 8.8.8.8 for now - and a server block for TLS termination.

I am using a modified version of the Docker NGINX Ingress that already has a self-signed certificate I can use:

nginx.conf

user  nginx;
worker_processes  auto;
worker_rlimit_nofile  15000;
pid  /var/run/nginx.pid;
include /usr/share/nginx/modules/*.conf;

events {
    worker_connections  2048;
    multi_accept on;
    use epoll;
}

stream {
    # DNS upstream pool
    upstream dns {
        zone dns 64k;
        server 8.8.8.8:53;
    }

    # DoT server for decryption
    server {
        listen 853 ssl;
        # ssl_certificate /etc/nginx/ssl/certs/doh.local.pem;
        # ssl_certificate_key /etc/nginx/ssl/private/doh.local.pem;
        proxy_pass dns;
        
        include self-signed.conf;
        include ssl-params.conf;
    }
}

self-signed.conf

ssl_certificate /etc/nginx/conf.d/ssl/certs/nginx-selfsigned.crt;
ssl_certificate_key /etc/nginx/conf.d/ssl/private/nginx-selfsigned.key;

ssl-params.conf

ssl_protocols TLSv1.3;
ssl_prefer_server_ciphers on;
ssl_dhparam /etc/nginx/conf.d/ssl/private/dhparam.pem;
ssl_ciphers ECDH+AESGCM:ECDH+CHACHA20:ECDH+AES256:ECDH+AES128:!aNULL:!SHA1:!AESCCM;
ssl_conf_command Options PrioritizeChaCha;
ssl_conf_command Ciphersuites TLS_AES_256_GCM_SHA384:TLS_AES_128_GCM_SHA256:TLS_CHACHA20_POLY1305_SHA256;
ssl_ecdh_curve secp384r1; # Requires nginx >= 1.1.0
ssl_session_timeout  10m;
ssl_session_cache shared:SSL:10m;
ssl_session_tickets off; # Requires nginx >= 1.5.9

Gateway Container

Start the docker container with:

docker network create --subnet=172.24.0.0/16 instar-net
docker pull nginx:alpine

docker run -d \
  --rm \
  --name=gateway \
  --net=instar-net \
  --ip=172.24.0.16 \
  -p 853:853 \
  -v /opt/bind9/gateway/nginx.conf:/etc/nginx/nginx.conf \
  -v /opt/bind9/gateway/self-signed.conf:/etc/nginx/self-signed.conf \
  -v /opt/bind9/gateway/ssl-params.conf:/etc/nginx/ssl-params.conf \
  -v /opt/bind9/gateway/ssl:/etc/nginx/conf.d/ssl \
  nginx:alpine

Testing

Verify that the TLS service is available - the IPv4 address 16.132.12.32, in the following command is the IP address of my Docker host server that is running the gateway:

openssl s_client -host 16.132.12.32 -port 853 -showcerts

CONNECTED(00000003)
Can't use SSL_get_servername
depth=0 C = AU, ST = Some-State, O = Internet Widgits Pty Ltd, CN = 16.132.12.32
verify error:num=18:self signed certificate
verify return:1
depth=0 C = AU, ST = Some-State, O = Internet Widgits Pty Ltd, CN = 16.132.12.32
verify return:1

...

I am going to use the getDNS Utilities to test the Gateway:

apt install getdns-utils

First I try a run without TLS - which cannot work. The IPv4 address 16.132.12.32, in the following command is the IP address of my Docker host server that is running the gateway:

getdns_query @16.132.12.32 -s -a -A www.instar.com

{
  "answer_type": GETDNS_NAMETYPE_DNS,
  "canonical_name": <bindata for www.instar.com.>,
  "just_address_answers": [],
  "replies_full": [],
  "replies_tree": [],
  "status": GETDNS_RESPSTATUS_ALL_TIMEOUT
}
An error occurred: The callback got a callback_type of 702. Exiting.
Error : 'The requested action timed out; response is filled in with empty structures'

And now to TLS:

getdns_query @16.132.12.32 -s -a -A  -l L www.instar.com

{
  "answer_ipv4_address": <bindata for 16.132.12.32>,
  "answer_type": GETDNS_NAMETYPE_DNS,
  "canonical_name": <bindata for www.instar.com.>,
  "just_address_answers":
  [
    {
      "address_data": <bindata for 49.12.0.118>,
      "address_type": <bindata of "IPv4">
    }
  ],
  
  ...

  "status": GETDNS_RESPSTATUS_GOOD
}

The IP address 49.12.0.118 is successfully resolved for www.instar.com.

Bind9 in Docker

I am going to use my Bind9 Docker Container and run it inside the same Docker network instar-net:

docker network create --subnet=172.24.0.0/16 instar-net
docker run -d --rm --name=ddns-master --net=instar-net --ip=172.24.0.2 ddns-master

Gateway Configuration

Now I can replace the 8.8.8.8 Google DNS Server and replace it with the Bind9 Docker container IP 172.24.0.2 or the container name ddns-master. I am also adding a stream for port 53 so I don't have to expose any ports from the Bind9 container:

user  nginx;
worker_processes  auto;
worker_rlimit_nofile  15000;
pid  /var/run/nginx.pid;
include /usr/share/nginx/modules/*.conf;


events {
    worker_connections  2048;
    multi_accept on;
    use epoll;
}

stream {
    # DNS Upstream
    upstream dns {
        zone dns 64k;
        server ddns-master:53;
    }

    # DNS Ingress
    server {
        listen 53 udp reuseport;
        proxy_timeout 20s;
        proxy_pass dns;
    }

    # DoT Ingress TLS Endpoint
    server {
        listen 853 ssl;
        # ssl_certificate /etc/nginx/ssl/certs/doh.local.pem;
        # ssl_certificate_key /etc/nginx/ssl/private/doh.local.pem;
        proxy_timeout 20s;
        proxy_pass dns;

        include self-signed.conf;
        include ssl-params.conf;
    }
}
docker run -d \
  --rm \
  --name=gateway \
  --net=instar-net \
  --ip=172.24.0.16 \
  -p 853:853/tcp \
  -p 53:53/udp \
  -v /opt/bind9/gateway/nginx.conf:/etc/nginx/nginx.conf \
  -v /opt/bind9/gateway/self-signed.conf:/etc/nginx/self-signed.conf \
  -v /opt/bind9/gateway/ssl-params.conf:/etc/nginx/ssl-params.conf \
  -v /opt/bind9/gateway/ssl:/etc/nginx/conf.d/ssl \
  nginx:alpine

Testing

Without TLS:

getdns_query @16.132.12.32 -s -a -A service2.instar-net.io                                                            
{
  "answer_ipv4_address": <bindata for 16.132.12.32>,
  "answer_type": GETDNS_NAMETYPE_DNS,
  "canonical_name": <bindata for service2.instar-net.io.>,
  "just_address_answers":
  [
    {
      "address_data": <bindata for 172.24.0.4>,
      "address_type": <bindata of "IPv4">
    }
  ],
  ...

  "status": GETDNS_RESPSTATUS_GOOD
}

But I am seeing an error message when switching to TLS:

getdns_query @16.132.12.32 -s -a -A  -l L service2.instar-net.io

{
  "answer_ipv4_address": <bindata for 16.132.12.32>,
  "answer_type": GETDNS_NAMETYPE_DNS,
  "canonical_name": <bindata for service2.instar-net.io.>,
  "just_address_answers": [],

  ...

  "status": GETDNS_RESPSTATUS_NO_NAME
}
An error occurred: The callback got a callback_type of 702. Exiting.
Error : 'The requested action timed out; response is filled in with empty structures'

DNSUtils

I am able to resolve the IP of my service as defined in Bind9 using nslookup:

apt-get install dnsutils

nslookup service2.instar-net.io 16.132.12.32

Server:         16.132.12.32
Address:        16.132.12.32#53

Name:   service2.instar-net.io
Address: 172.24.0.4

But there is no way to check the TLS connecting through the gateway proxy.

Knot DNSUtils

That is where kdig comes in:

apt-get install knot-dnsutils

Without TLS:

kdig service1.instar-net.io -t A @16.132.12.32

;; TLS session (TLS1.3)-(ECDHE-SECP384R1)-(RSA-PSS-RSAE-SHA256)-(AES-256-GCM)
;; ->>HEADER<<- opcode: QUERY; status: NOERROR; id: 17361
;; Flags: qr aa rd ra; QUERY: 1; ANSWER: 1; AUTHORITY: 0; ADDITIONAL: 1

;; EDNS PSEUDOSECTION:
;; Version: 0; flags: ; UDP size: 1232 B; ext-rcode: NOERROR
                                                                       
;; ->>HEADER<<- opcode: QUERY; status: NOERROR; id: 16017
;; Flags: qr aa rd ra; QUERY: 1; ANSWER: 1; AUTHORITY: 0; ADDITIONAL: 0

;; QUESTION SECTION:
;; service2.instar-net.io.              IN      A

;; ANSWER SECTION:
service2.instar-net.io. 86400   IN      A       172.24.0.4

;; Received 56 B
;; Time 2022-03-10 08:31:07 CET
;; From 1.2.3.4@53(UDP) in 0.0 ms

And here it also seems to be working over TLS - I am getting the 172.24.0.3 resolution for Service1, as expected:

kdig +tcp +tls -p 853 service1.instar-net.io -t A @16.132.12.32
                                                        
;; TLS session (TLS1.3)-(ECDHE-SECP384R1)-(RSA-PSS-RSAE-SHA256)-(AES-256-GCM)
;; ->>HEADER<<- opcode: QUERY; status: NOERROR; id: 57694
;; Flags: qr aa rd ra; QUERY: 1; ANSWER: 1; AUTHORITY: 0; ADDITIONAL: 1

;; EDNS PSEUDOSECTION:
;; Version: 0; flags: ; UDP size: 1232 B; ext-rcode: NOERROR

;; QUESTION SECTION:
;; service1.instar-net.io.              IN      A

;; ANSWER SECTION:
service1.instar-net.io. 86400   IN      A       172.24.0.3

;; Received 67 B
;; Time 2022-03-09 13:33:15 CET
;; From 16.132.12.32@853(TCP) in 1.4 ms

And the forwarder is working as well:

kdig +tcp +tls -p 853 www.instar.com -t A @16.132.12.32

;; TLS session (TLS1.3)-(ECDHE-SECP384R1)-(RSA-PSS-RSAE-SHA256)-(AES-256-GCM)
;; ->>HEADER<<- opcode: QUERY; status: NOERROR; id: 22289
;; Flags: qr rd ra; QUERY: 1; ANSWER: 1; AUTHORITY: 0; ADDITIONAL: 1

;; EDNS PSEUDOSECTION:
;; Version: 0; flags: ; UDP size: 1232 B; ext-rcode: NOERROR

;; QUESTION SECTION:
;; www.instar.com.              IN      A

;; ANSWER SECTION:
www.instar.com.         600     IN      A       49.12.0.118

;; Received 59 B
;; Time 2022-03-10 04:34:23 CET
;; From 16.132.12.32@853(TCP) in 41.4 ms

Docker-Compose

Wrapping everything up into a compose file:

version: "3.9"

services:
  bind:
    build:
      context: .
      dockerfile: Dockerfile
    image: dns-master:latest
    networks:
      bindnet:
        ipv4_address: 172.24.0.2
          

  gateway:
    image: nginx:alpine
    ports:
      - "53:53/udp"
      - "853:853/tcp"
    networks:
      bindnet:
        ipv4_address: 172.24.0.16
    volumes:
      - /opt/bind9/gateway/nginx.conf:/etc/nginx/nginx.conf
      - /opt/bind9/gateway/self-signed.conf:/etc/nginx/self-signed.conf
      - /opt/bind9/gateway/ssl-params.conf:/etc/nginx/ssl-params.conf
      - /opt/bind9/gateway/ssl:/etc/nginx/conf.d/ssl

networks:
  bindnet:
    driver: bridge
    ipam:
      driver: default
      config:
      - subnet:  172.24.0.0/16

To execute the compose file first run docker-compose build to build the Bind9 Docker container - the Dockerfile and all dependencies have to be in the same directory as the compose file:

FROM internetsystemsconsortium/bind9:9.18

RUN apt update \
  && apt install -y \
  bind9-doc \
  dnsutils \
  geoip-bin \
  mariadb-server \
  net-tools

# Copy configuration files
COPY configuration/named.conf.options /etc/bind/
COPY configuration/named.conf.local /etc/bind/
COPY configuration/db.instar-net.io /etc/bind/zones/

# Expose Ports
EXPOSE 53/tcp
EXPOSE 53/udp
EXPOSE 953/tcp

# Start the Name Service
CMD ["/usr/sbin/named", "-g", "-c", "/etc/bind/named.conf", "-u", "bind"]